nanoeffects 0.26.0 → 0.28.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.
- package/README.md +85 -30
- package/dist/{action-creator-Bpa8BUS9.d.ts → action-creator-CcAEvUnw.d.ts} +90 -23
- package/dist/actions.d.ts +54 -21
- package/dist/actions.js +1 -1
- package/dist/bus-DlpEluzd.js +1 -0
- package/dist/{context-types-BKIH5a8s.d.ts → context-types-hCKgU8sC.d.ts} +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/dev.d.ts +2 -2
- package/dist/dev.js +1 -1
- package/dist/effects.d.ts +2 -2
- package/dist/event-DVcW0mME.js +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/match.d.ts +1 -1
- package/dist/{module-B6rg5NIb.d.ts → module-C_oEXw1n.d.ts} +40 -57
- package/dist/notrace/{action-creator-D2CotugT.d.ts → action-creator-DxOvVo8I.d.ts} +90 -23
- package/dist/notrace/actions.d.ts +114 -98
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/bus-DmKn4SDD.js +1 -0
- package/dist/notrace/effects.d.ts +2 -2
- package/dist/notrace/index.d.ts +2 -2
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/{plugin-Dz6hq8Jg.d.ts → plugin-lJjjd-SN.d.ts} +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/policies.js +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/observable.d.ts +6 -6
- package/dist/oop.d.ts +2 -2
- package/dist/oop.js +1 -1
- package/dist/{plugin-D-j1DarG.d.ts → plugin-CuvIgjnX.d.ts} +2 -2
- package/dist/policies.d.ts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/redux.d.ts +1 -1
- package/dist/scope.d.ts +2 -2
- package/dist/signal.d.ts +1 -1
- package/dist/signal.js +1 -1
- package/dist/svelte.d.ts +1 -1
- package/dist/trace.d.ts +1 -1
- package/dist/{types-Bkuhnkq6.d.ts → types-FV8fuvI9.d.ts} +2 -2
- package/dist/vue.d.ts +1 -1
- package/dist/{with-policy-BeH8wCkX.d.ts → with-policy-3bhFLEXs.d.ts} +1 -1
- package/docs/README.md +5 -4
- package/docs/actions.md +94 -53
- package/docs/chat-example.md +8 -8
- package/docs/chat-widget.md +135 -0
- package/docs/context.md +1 -1
- package/docs/errors.md +1 -1
- package/docs/guide.md +26 -26
- package/docs/hmr.md +1 -1
- package/docs/motivation.md +10 -4
- package/docs/oop.md +1 -1
- package/docs/package.md +2 -2
- package/docs/plugins.md +23 -23
- package/docs/query-libraries.md +14 -14
- package/docs/scope.md +22 -22
- package/docs/server.md +12 -12
- package/docs/stability.md +2 -2
- package/docs/starter.md +3 -3
- package/docs/tracing.md +5 -5
- package/docs/typescript.md +16 -16
- package/llms-full.txt +380 -194
- package/llms.txt +3 -2
- package/package.json +5 -4
- package/dist/bus-hHp-T7JV.js +0 -1
- package/dist/event-0RIBGYqS.js +0 -1
- package/dist/notrace/bus-BdyvumpX.js +0 -1
- package/dist/notrace/promise-CSLpzwSR.js +0 -1
package/docs/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
## Start here
|
|
4
4
|
|
|
5
|
-
The [Guide](guide.md) is the ladder: small bus → policies for async UI → effects when you need them →
|
|
5
|
+
The [Guide](guide.md) is the ladder: small bus → policies for async UI → effects when you need them → handlers and modules for domain logic off the UI.
|
|
6
6
|
|
|
7
|
-
Why the library exists, and when to pick it over
|
|
7
|
+
Why the library exists (logic and side effects outside React, Vue and Svelte components, as plain `async` functions), and when to pick it over redux-saga, RxJS, Redux or Effector (including SDKs and Module Federation): [Why nanoeffects exists](motivation.md).
|
|
8
8
|
|
|
9
9
|
Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-checked and tested).
|
|
10
10
|
|
|
@@ -15,10 +15,11 @@ Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-che
|
|
|
15
15
|
| Small app / widget (rare races) | [Guide § Create a bus](guide.md#1-create-a-bus) |
|
|
16
16
|
| SPA with async UI | [Policies](policies.md), [Guide § latest](guide.md#3-abort-the-previous-run-with-latest) |
|
|
17
17
|
| Unreliable network / long flows | [Effects](effects.md) — one helper per job, then the rest as reference |
|
|
18
|
-
| Domain logic off the UI | [Actions,
|
|
18
|
+
| Domain logic off the UI | [Actions, handlers, modules](actions.md) |
|
|
19
19
|
| Already on Redux / Zustand / MobX / Pinia | [Stores](stores.md) |
|
|
20
20
|
| A Node server (Fastify) whose work must stop when the client leaves | [Stop work on a server](server.md) |
|
|
21
21
|
| A React form with async checks | [Build a sign-up form in React](react.md) |
|
|
22
|
+
| A site widget that listens only while open | [Build a chat widget](chat-widget.md) |
|
|
22
23
|
| A Vue app | [Use nanoeffects in a Vue app](vue.md) |
|
|
23
24
|
| A Svelte app | [Use nanoeffects in a Svelte app](svelte.md) |
|
|
24
25
|
| Old responses overwrite new ones | [Cancel stale requests](cancel-stale-requests.md) |
|
|
@@ -33,7 +34,7 @@ Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-che
|
|
|
33
34
|
|
|
34
35
|
- [Policies](policies.md) — `latest`, `leading`, `debounce`, `throttle`, `queue`, `key`
|
|
35
36
|
- [Effects and cancellation](effects.md) — `retry`, `timeout`, `poll`, `take`, `all`, …
|
|
36
|
-
- [Actions,
|
|
37
|
+
- [Actions, jobs and modules](actions.md)
|
|
37
38
|
- [Sign-up form in React](react.md) — one form: races, double submit, retry
|
|
38
39
|
- [nanoeffects in a Vue app](vue.md) — `watchEmitter` and `useEventRef`
|
|
39
40
|
- [nanoeffects in a Svelte app](svelte.md) — `eventStore`
|
package/docs/actions.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Actions,
|
|
1
|
+
# Actions, jobs and modules
|
|
2
2
|
|
|
3
3
|
Use this page when **domain logic lives on the bus**, off the UI: the UI emits and listens; handlers and modules own the work.
|
|
4
4
|
|
|
@@ -10,7 +10,9 @@ A chat with two entities and three features, jobs under policies and effects, an
|
|
|
10
10
|
|
|
11
11
|
A whole project with two domains, a job and React as a thin layer: `npm create nanoeffects@latest my-app -- --template domains-ts` (see [Start a project from a template](starter.md#start-with-domains)).
|
|
12
12
|
|
|
13
|
-
## Return a value from `emit` with a
|
|
13
|
+
## Return a value from `emit` with a handler
|
|
14
|
+
|
|
15
|
+
Give `createAction` a handler, and `emit` runs it and returns its result. There is one creator for an event and for an action with work behind it; the handler is the only difference.
|
|
14
16
|
|
|
15
17
|
The bus lives in one file and its events in another, so any module can import both without a cycle.
|
|
16
18
|
|
|
@@ -24,10 +26,10 @@ export const bus = createBus();
|
|
|
24
26
|
|
|
25
27
|
```js
|
|
26
28
|
// math.js
|
|
27
|
-
import {
|
|
29
|
+
import { createAction } from "nanoeffects/actions";
|
|
28
30
|
|
|
29
|
-
//
|
|
30
|
-
export const double =
|
|
31
|
+
// createAction with a handler — emit runs the handler and returns its result
|
|
32
|
+
export const double = createAction("math/double", (value) => value * 2);
|
|
31
33
|
```
|
|
32
34
|
|
|
33
35
|
```js
|
|
@@ -37,27 +39,24 @@ import { double } from "./math";
|
|
|
37
39
|
bus.emit(double(21)); // 42
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
## Describe events,
|
|
42
|
+
## Describe events, handlers and jobs
|
|
41
43
|
|
|
42
44
|
`nanoeffects/actions` has the pieces to name what a bus does, apart from the code that does it.
|
|
43
45
|
|
|
44
46
|
```js
|
|
45
47
|
// user.js
|
|
46
|
-
import { createAction,
|
|
48
|
+
import { createAction, createJob } from "nanoeffects/actions";
|
|
47
49
|
|
|
48
|
-
// createAction — event with a payload (like RTK createAction)
|
|
50
|
+
// createAction — an event with a payload (like RTK createAction)
|
|
49
51
|
export const userLoaded = createAction("user/loaded");
|
|
50
52
|
export const loadFailed = createAction("user/failed");
|
|
51
53
|
|
|
52
|
-
//
|
|
53
|
-
export const getUser =
|
|
54
|
+
// createAction with a handler — emit returns what the handler returns
|
|
55
|
+
export const getUser = createAction("user/get", (id, { signal }) => {
|
|
54
56
|
return fetchUser(id, { signal });
|
|
55
57
|
});
|
|
56
58
|
|
|
57
|
-
//
|
|
58
|
-
export const sendMail = createCommand("mail/send");
|
|
59
|
-
|
|
60
|
-
// createJob — command that also emits success / fail actions
|
|
59
|
+
// createJob — an action with a handler that also emits success / fail actions
|
|
61
60
|
export const loadUser = createJob("user/load", fetchUser, {
|
|
62
61
|
success: userLoaded,
|
|
63
62
|
fail: "user/failed",
|
|
@@ -73,57 +72,99 @@ These are the events of the user domain, in a file of their own (`user.js`). The
|
|
|
73
72
|
- `userLoaded(user)` → `{ type, payload }`
|
|
74
73
|
- `userLoaded.type` — the type string
|
|
75
74
|
- `userLoaded.match(action)` — narrows an action
|
|
76
|
-
- `createAction(type,
|
|
75
|
+
- `createAction(type, handler)` — a function as the second argument is the handler; `bus.emit(action(payload))` tells the listeners, runs the handler as a task and returns its result
|
|
76
|
+
- `createAction(type, { prepare })` — build the payload from several arguments
|
|
77
|
+
- `createAction(type, { handle, prepare, ...scope })` — everything in one object: `handle` and `prepare` are taken out, and every other field is the [scope](scope.md)
|
|
77
78
|
- Works as the event of its type: `bus.emit(userLoaded(user))`, `bus.on(userLoaded, fn)`
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
A scope goes after a handler as the last argument, `createAction(type, handler, scope)`; `prepare` with a handler goes in that last object: `createAction(type, handler, { prepare, ...scope })`.
|
|
81
|
+
|
|
82
|
+
- Wrap the handler in a policy: `createAction("search", latest(fn))`.
|
|
80
83
|
|
|
81
|
-
|
|
84
|
+
An action with no handler is an event. Emitting it does not throw, and `onUnhandled` hears about it when nobody listens.
|
|
82
85
|
|
|
83
|
-
|
|
84
|
-
- `command.match(action)` narrows like an action creator’s `match`.
|
|
86
|
+
### Bind a handler later
|
|
85
87
|
|
|
86
|
-
**
|
|
88
|
+
**Problem.** The work belongs to another layer, or a test must replace it. Putting the handler in `createAction` then ties the event file to the work.
|
|
89
|
+
|
|
90
|
+
**When.** Use the registry when the handler is not known where the action is made. The action can have no handler at all; a handler bound to its type answers it. Each bus (each test) has its own registry.
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
// mail.js
|
|
94
|
+
import { createAction } from "nanoeffects/actions";
|
|
95
|
+
|
|
96
|
+
// createAction without a handler — an event; the work is bound elsewhere
|
|
97
|
+
export const sendMail = createAction("mail/send");
|
|
98
|
+
```
|
|
87
99
|
|
|
88
100
|
```js
|
|
89
101
|
// bus.js
|
|
90
102
|
import { createBus } from "nanoeffects";
|
|
91
|
-
import {
|
|
92
|
-
import { sendMail } from "./
|
|
103
|
+
import { actionsPlugin, setHandler } from "nanoeffects/actions";
|
|
104
|
+
import { sendMail } from "./mail";
|
|
93
105
|
|
|
94
106
|
export const bus = createBus();
|
|
95
107
|
|
|
96
|
-
//
|
|
97
|
-
export const
|
|
98
|
-
|
|
108
|
+
// actionsPlugin — the registry of handlers of this bus; setHandler pairs an action with its work
|
|
109
|
+
export const actions = bus.use(
|
|
110
|
+
actionsPlugin([setHandler(sendMail, smtpSend)]),
|
|
99
111
|
);
|
|
100
112
|
```
|
|
101
113
|
|
|
102
114
|
```js
|
|
103
|
-
import {
|
|
115
|
+
import { actions } from "./bus";
|
|
104
116
|
import { getUser } from "./user";
|
|
105
117
|
|
|
106
|
-
// later, e.g. in a test — latest binding wins
|
|
107
|
-
const unbind =
|
|
118
|
+
// later, e.g. in a test — the latest binding wins, a handler bound here wins over the one in createAction
|
|
119
|
+
const unbind = actions.handle(getUser, async () => fakeUser);
|
|
108
120
|
unbind();
|
|
109
121
|
```
|
|
110
122
|
|
|
111
|
-
A
|
|
123
|
+
A handler bound in the registry makes `bus.emit(sendMail(mail))` run it and return its result, the same as for an action that carries its handler. The listeners of the type are told first. With no handler bound, `sendMail` is an event again.
|
|
124
|
+
|
|
125
|
+
The registry lives in `nanoeffects/actions`, so a bus without one carries none of that code. Without a registry, `emit` runs the handler that the action carries. `bus.use(actionsPlugin())` sets `bus.actions`. A second `actionsPlugin` binds its list in that registry and returns it. There is no `actions` option in `createBus`; assign a custom runner with `bus.actions = { run }`, and add `bound(type)` to it to answer for actions that have no handler of their own.
|
|
126
|
+
|
|
127
|
+
### Say what a bound handler returns
|
|
128
|
+
|
|
129
|
+
TypeScript cannot see which handler the registry has, so the type of `emit` for an action without a handler is `void`. Tell it what the bound handler returns with `Call<Payload, Result>`. This is a promise of yours: nothing checks it against the handler.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// mail.ts
|
|
133
|
+
import type { Call } from "nanoeffects";
|
|
134
|
+
import { createAction } from "nanoeffects/actions";
|
|
135
|
+
|
|
136
|
+
// Call — payload Mail, a bound handler answers with a boolean (checker only; nothing runs differently)
|
|
137
|
+
type Mail = { to: string };
|
|
138
|
+
export const sendMail = createAction<Call<Mail, boolean>>("mail/send");
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
In an event map the same type does the job for string events, and `emit` returns `boolean`:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
// events.ts
|
|
145
|
+
import type { Call } from "nanoeffects";
|
|
146
|
+
|
|
147
|
+
// Call in the map — the payload is checked, emit returns the result (checker only)
|
|
148
|
+
export type Events = {
|
|
149
|
+
"user/getId": Call<void, string>;
|
|
150
|
+
"user/loaded": { id: string };
|
|
151
|
+
};
|
|
152
|
+
```
|
|
112
153
|
|
|
113
|
-
|
|
154
|
+
On a bus without an event map you can say it where you call: `const id = bus.emit<string>("user/getId")`. A bus with a map takes no such hint, so its names and payloads stay checked.
|
|
114
155
|
|
|
115
156
|
### Jobs
|
|
116
157
|
|
|
117
|
-
`createJob(type, handler, { pending, success, fail, cancel })` makes a
|
|
158
|
+
`createJob(type, handler, { pending, success, fail, cancel })` makes an action with a handler that also sends its outcome as actions, when nobody waits for the result. It returns the action with `loadUser.pending`, `loadUser.success`, `loadUser.fail` and `loadUser.cancel` on it.
|
|
118
159
|
|
|
119
160
|
- When the handler starts, emits `loadUser.pending(payload)`.
|
|
120
161
|
- On success, emits `loadUser.success(result)`.
|
|
121
162
|
- On failure, emits `loadUser.fail(error)`.
|
|
122
163
|
- When the task is aborted before the handler settles, emits `loadUser.cancel(reason)` instead of `success` or `fail`. After `pending`, exactly one of the three follows.
|
|
123
164
|
- `pending` / `success` / `fail` / `cancel` are a string (job makes an action creator) or an existing action creator. Missing ones become `<type>/pending`, `<type>/success`, `<type>/fail` or `<type>/cancel`.
|
|
124
|
-
- `bus.emit(loadUser(id))` returns a promise of the result. Handler errors are always handled: they go to `fail` and the promise resolves with `undefined`. For a result that must reject,
|
|
125
|
-
- Outcome actions are emitted as the task that ran the job (also for a handler bound with `
|
|
126
|
-
- A
|
|
165
|
+
- `bus.emit(loadUser(id))` returns a promise of the result. Handler errors are always handled: they go to `fail` and the promise resolves with `undefined`. For a result that must reject, give `createAction` the handler.
|
|
166
|
+
- Outcome actions are emitted as the task that ran the job (also for a handler bound with `actions.handle`). `success` and `fail` are not sent after abort; `cancel` is, at the moment of the abort.
|
|
167
|
+
- A job without a handler is not an outcome: `emit` throws `No handler for command "..."` and nothing is sent. An action without a handler is an event and does not throw.
|
|
127
168
|
- Calling the job only builds `{ type, payload }`; the handler runs on `emit`.
|
|
128
169
|
- The outcomes are events like any other: with `onUnhandled` set on the bus, it is called for a `pending` or a `cancel` that nobody listens to.
|
|
129
170
|
|
|
@@ -147,7 +188,7 @@ export const bus = createBus();
|
|
|
147
188
|
// user.js
|
|
148
189
|
import { createJob } from "nanoeffects/actions";
|
|
149
190
|
|
|
150
|
-
// createJob — a
|
|
191
|
+
// createJob — an action with a handler that also sends pending, success, fail and cancel
|
|
151
192
|
export const loadUser = createJob("user/load", fetchUser);
|
|
152
193
|
```
|
|
153
194
|
|
|
@@ -202,18 +243,18 @@ A flag is exact when calls do not overlap, or when `latest` replaces them. If se
|
|
|
202
243
|
|
|
203
244
|
## Mount domains with modules
|
|
204
245
|
|
|
205
|
-
**Problem.** A domain needs several subscriptions and
|
|
246
|
+
**Problem.** A domain needs several subscriptions and handler bindings. With plain `bus.on` / `actions.handle` you must remember every unsubscribe.
|
|
206
247
|
|
|
207
248
|
**When.** You want one mount / one unmount for that domain. Skip modules while a few top-level `bus.on` calls are enough.
|
|
208
249
|
|
|
209
|
-
`bus.use(modulePlugin(modules))` assembles an app from modules and returns `mount`, `unmount`, `isMounted`, `dispose`, `
|
|
250
|
+
`bus.use(modulePlugin(modules))` assembles an app from modules and returns `mount`, `unmount`, `isMounted`, `dispose`, `actions`, and `createModule`: the same as `createModule` below, with the context fields of the bus typed (see [TypeScript](typescript.md)).
|
|
210
251
|
|
|
211
252
|
```js
|
|
212
253
|
// user.js
|
|
213
|
-
import {
|
|
254
|
+
import { createAction } from "nanoeffects/actions";
|
|
214
255
|
|
|
215
|
-
export const getUser =
|
|
216
|
-
export const saveUser =
|
|
256
|
+
export const getUser = createAction("user/get");
|
|
257
|
+
export const saveUser = createAction("user/save", persistUser);
|
|
217
258
|
```
|
|
218
259
|
|
|
219
260
|
```js
|
|
@@ -250,7 +291,7 @@ import { app } from "./bus";
|
|
|
250
291
|
import { getUser } from "./user";
|
|
251
292
|
|
|
252
293
|
const unmount = app.mount(orderModule); // later, e.g. after a lazy import
|
|
253
|
-
app.
|
|
294
|
+
app.actions.handle(getUser, fakeUser); // in a test
|
|
254
295
|
app.dispose(); // unmounts every module
|
|
255
296
|
```
|
|
256
297
|
|
|
@@ -281,37 +322,37 @@ app.unmount(...modules); // also in reverse order
|
|
|
281
322
|
|
|
282
323
|
If the `setup` of one module throws, the modules of that call that were already mounted are unmounted again and the error is thrown, so a call mounts all of its modules or none. A module that is mounted already is skipped.
|
|
283
324
|
|
|
284
|
-
- **Unmounting undoes everything** the module did: subscriptions, bound
|
|
325
|
+
- **Unmounting undoes everything** the module did: subscriptions, bound handlers, sub-modules, and background tasks (aborted).
|
|
285
326
|
- **A module is known by its name.** Mounting a module whose name is taken replaces the mounted one (see [Hot reload](hmr.md)).
|
|
286
|
-
- **Registry.** If `bus.use(
|
|
327
|
+
- **Registry.** If `bus.use(actionsPlugin())` ran first, that registry is used; otherwise the module plugin attaches its own. Either way it is `bus.actions` and `app.actions`.
|
|
287
328
|
- A disposed app cannot mount modules.
|
|
288
|
-
- **The given modules are mounted at the end of the block that made the app**, or at the first use of the app or the bus if that comes first (the first `bus.emit`, or a call of `mount`, `unmount`, `isMounted`, `dispose`, or the read of `
|
|
329
|
+
- **The given modules are mounted at the end of the block that made the app**, or at the first use of the app or the bus if that comes first (the first `bus.emit`, or a call of `mount`, `unmount`, `isMounted`, `dispose`, or the read of `actions`). Nothing needs to use the app: `bus.use(modulePlugin([a, b]))` is enough to start them. The plugins that you use after `modulePlugin` in the same block still reach them, because the modules wait until that block has finished. A plugin used after an `await` can come too late: use every plugin before the first `await`. The `bus.on` calls made in between are held and made right after, so the listeners of a module stay ahead of them. See [using plugins in any order](plugins.md#use-them-in-any-order).
|
|
289
330
|
- **To mount by hand instead**, pass no modules: `modulePlugin()` starts nothing, and `app.mount(...)` starts the modules when you want.
|
|
290
331
|
- Module context = task context plus `handle`, `use`, and `spawn`:
|
|
291
|
-
- `handle` — give
|
|
292
|
-
- `use` — bring sub-modules and
|
|
332
|
+
- `handle` — give an action a handler (one that has none, or an override).
|
|
333
|
+
- `use` — bring sub-modules and actions that carry a handler; the returned function undoes that call.
|
|
293
334
|
- `spawn` — background task, aborted on unmount.
|
|
294
|
-
-
|
|
335
|
+
- An action that carries a handler keeps answering after unmount (`use` only tracks registration). Use `handle` for a handler that must live only as long as the module.
|
|
295
336
|
|
|
296
|
-
## Give
|
|
337
|
+
## Give an action or a module its own fields
|
|
297
338
|
|
|
298
|
-
|
|
339
|
+
An action with a handler can need its own client, a module its own logger. Pass a scope, a plain object whose keys go on `ctx` (`$effects` binds functions to the task), as the last argument of `createAction`, `createAction`, `createModule` and `createJob`, and turn it on with `scopePlugin`:
|
|
299
340
|
|
|
300
341
|
```js
|
|
301
342
|
// bus.js
|
|
302
343
|
import { createBus } from "nanoeffects";
|
|
303
344
|
import { scopePlugin } from "nanoeffects/scope";
|
|
304
345
|
|
|
305
|
-
// scopePlugin — makes the scope of
|
|
346
|
+
// scopePlugin — makes the scope of an action work on this bus
|
|
306
347
|
export const bus = createBus().use(scopePlugin());
|
|
307
348
|
```
|
|
308
349
|
|
|
309
350
|
```js
|
|
310
351
|
// user.js
|
|
311
|
-
import {
|
|
352
|
+
import { createAction } from "nanoeffects/actions";
|
|
312
353
|
|
|
313
|
-
// the third argument — fields of this
|
|
314
|
-
export const getUser =
|
|
354
|
+
// the third argument — fields of this action only
|
|
355
|
+
export const getUser = createAction(
|
|
315
356
|
"user/get",
|
|
316
357
|
(id, { api }) => api.getUser(id),
|
|
317
358
|
{ api },
|
package/docs/chat-example.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Use this page when your app has several features that react to each other and you want to see where each piece of logic goes. The example is a chat with rooms, a history that loads, messages that can fail to send, a search across all rooms, and new messages that arrive on their own. A small Fastify server in the example answers every request, and it misbehaves on a known schedule, so each problem on this page really happens.
|
|
4
4
|
|
|
5
|
-
It is a level D example. Start with [Actions,
|
|
5
|
+
It is a level D example. Start with [Actions, jobs and modules](actions.md) for the API, or with the `domains-ts` template in [Start a project from a template](starter.md#start-with-domains) for a smaller project.
|
|
6
6
|
|
|
7
7
|
The code is in [`examples/chat`](../examples/chat). Run it on [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat), or on your machine:
|
|
8
8
|
|
|
@@ -48,7 +48,7 @@ The code is in layers, and a file imports only from the layers below it. The com
|
|
|
48
48
|
|
|
49
49
|
That rule is why `show-history` exists. Selecting a room must load its history, but `room` does not know `message` and `message` does not know `room`. The feature `show-history` is the one place that knows both: it listens to `rooms/selected` and starts the job `messages/loadHistory`. The same goes for a message that arrives: `receive-messages` emits `incoming/received`, an event that the `message` entity owns and listens to.
|
|
50
50
|
|
|
51
|
-
A folder has its events (`room.ts`, which imports no bus), its work (`room-handlers.ts`), the state it keeps (`room-store.ts`) and a module (`room-module.ts`) that wires them. Another layer reaches an entity through its
|
|
51
|
+
A folder has its events (`room.ts`, which imports no bus), its work (`room-handlers.ts`), the state it keeps (`room-store.ts`) and a module (`room-module.ts`) that wires them. Another layer reaches an entity through its actions, never through its handlers or its store. All actions live in the events file. An action with no handler says that something happened, and any feature may listen: `rooms/selected` is heard by two. An action with a handler is an order with exactly one handler, as `search/openResult` and `messages/mark` are. The handler is given to `createAction`, so the action works wherever it is called and can return a result, and the module binds it with `module.use` for as long as it is mounted. A job is an action with a handler that also sends its outcomes, but its handler is bound in the module (`module.handle`): a job usually runs under a policy, and the type of its outcomes is exact only when the handler is not part of the type.
|
|
52
52
|
|
|
53
53
|
The layers come from [Feature-Sliced Design](https://feature-sliced.design/), reduced to the three that an app of this size needs.
|
|
54
54
|
|
|
@@ -64,7 +64,7 @@ A job sends `pending` when its handler starts and exactly one of `success`, `fai
|
|
|
64
64
|
// message.js
|
|
65
65
|
import { createJob } from "nanoeffects/actions";
|
|
66
66
|
|
|
67
|
-
// createJob — a
|
|
67
|
+
// createJob — an action with a handler that also sends pending, success, fail and cancel
|
|
68
68
|
export const loadHistory = createJob("messages/loadHistory");
|
|
69
69
|
```
|
|
70
70
|
|
|
@@ -179,11 +179,11 @@ export const runSearch = debounce(
|
|
|
179
179
|
`take` waits for the next event of a name. Start it before the emit that causes the event, so nothing is missed:
|
|
180
180
|
|
|
181
181
|
```js
|
|
182
|
-
import {
|
|
182
|
+
import { createAction } from "nanoeffects/actions";
|
|
183
183
|
import { take } from "nanoeffects/effects";
|
|
184
184
|
|
|
185
|
-
//
|
|
186
|
-
const markMessage =
|
|
185
|
+
// createAction with a handler — an order for the message entity, with the handler that does it
|
|
186
|
+
const markMessage = createAction("messages/mark", (messageId) => console.log("mark", messageId));
|
|
187
187
|
|
|
188
188
|
// the room that is open, kept from the rooms/selected event
|
|
189
189
|
let selectedRoom = "general";
|
|
@@ -195,7 +195,7 @@ export const goToResult = async (hit, ctx) => {
|
|
|
195
195
|
ctx.emit("rooms/selected", hit.roomId);
|
|
196
196
|
await loaded;
|
|
197
197
|
}
|
|
198
|
-
// ctx.emit with a
|
|
198
|
+
// ctx.emit with an action that has a handler — the message entity marks the message
|
|
199
199
|
ctx.emit(markMessage(hit.id));
|
|
200
200
|
};
|
|
201
201
|
```
|
|
@@ -247,6 +247,6 @@ Each entity and each feature that has state keeps it in a [Zustand](https://zust
|
|
|
247
247
|
|
|
248
248
|
## Where to go next
|
|
249
249
|
|
|
250
|
-
- The API of
|
|
250
|
+
- The API of actions, jobs and modules: [Actions, jobs and modules](actions.md).
|
|
251
251
|
- Policies such as `latest` and `queue`: [Policies](policies.md). Effects such as `allSettled`, `take` and `poll`: [Effects](effects.md).
|
|
252
252
|
- A smaller project with two domains: [Start with domains](starter.md#start-with-domains). Its two domains do not talk to each other, so it needs no layers.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Build a chat widget that listens only while it is open
|
|
2
|
+
|
|
3
|
+
Use this page when a site has a chat with a bot behind a launcher button, and the chat must cost nothing while it is closed. Closed means no subscriptions on the bus, no polling, no keyboard listener, and no request still running. Opening turns all of it on, and closing turns all of it off in one call.
|
|
4
|
+
|
|
5
|
+
The code is in [`examples/chat-widget`](../examples/chat-widget): plain DOM and TypeScript, a fake bot in the browser, no server. Run it on [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat-widget), or on your machine:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd examples/chat-widget
|
|
9
|
+
npm install
|
|
10
|
+
npm run dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
It is a level D example: the logic lives in one module, and the page only mounts and unmounts it. For an app with several features see [Build a chat with entities, features, modules and a small server](chat-example.md).
|
|
14
|
+
|
|
15
|
+
## Know what happens on open and on close
|
|
16
|
+
|
|
17
|
+
| Moment | What happens |
|
|
18
|
+
| ------ | ------------ |
|
|
19
|
+
| Open | The module is mounted: the bot handler is subscribed, the operator messages are polled, the `Escape` listener is added. The panel is drawn and listens to the bus. |
|
|
20
|
+
| The user sends a text | The bot is asked, one text after another, with a time limit and a retry. |
|
|
21
|
+
| `Escape` | The module emits `chat/closeRequested`; the launcher closes the chat. |
|
|
22
|
+
| Close | The panel is removed and unsubscribed, the module is unmounted. A request in flight is aborted, the polling stops, the `Escape` listener goes away. |
|
|
23
|
+
|
|
24
|
+
## Find the parts in the files
|
|
25
|
+
|
|
26
|
+
| File | Job |
|
|
27
|
+
| ---- | --- |
|
|
28
|
+
| `src/bus.ts` | The bus, and `modulePlugin()` with no module: the launcher mounts the module itself. |
|
|
29
|
+
| `src/chat.ts` | The events only: `messageSent`, `messageAdded`, `typingChanged`, `closeRequested`. |
|
|
30
|
+
| `src/chat-handlers.ts` | `askBot` (`timeout` and `retry` inside) and `watchOperator` (`poll`): plain `async` functions that know nothing about the page. |
|
|
31
|
+
| `src/chat-module.ts` | The list of what is alive while the chat is open. |
|
|
32
|
+
| `src/panel.ts` | The view: shows the lines, sends the texts, returns one function that removes it. |
|
|
33
|
+
| `src/main.ts` | The launcher: the only file that knows both the module and the panel. |
|
|
34
|
+
|
|
35
|
+
## Bind everything in one module
|
|
36
|
+
|
|
37
|
+
A module is the list of what is alive while the chat is open. When it unmounts, the bus undoes every line of it.
|
|
38
|
+
|
|
39
|
+
<!-- docs-check: skip - the other files of the example are in examples/chat-widget -->
|
|
40
|
+
```js
|
|
41
|
+
// chat-module.js
|
|
42
|
+
import { createModule } from "nanoeffects/actions";
|
|
43
|
+
import { queue } from "nanoeffects/policies";
|
|
44
|
+
import { closeRequested, messageSent } from "./chat";
|
|
45
|
+
import { askBot, watchOperator } from "./chat-handlers";
|
|
46
|
+
|
|
47
|
+
// a task that stays pending until the module unmounts; its signal then aborts
|
|
48
|
+
const untilAborted = (ctx) =>
|
|
49
|
+
new Promise((resolve) => {
|
|
50
|
+
ctx.signal.addEventListener("abort", resolve, { once: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// a DOM listener that lives as long as the module: `signal` removes it when it aborts
|
|
54
|
+
const listenToEscape = async (_, ctx) => {
|
|
55
|
+
const onKey = (event) => {
|
|
56
|
+
if (event.key === "Escape") {
|
|
57
|
+
ctx.emit(closeRequested());
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
document.addEventListener("keydown", onKey, { signal: ctx.signal });
|
|
61
|
+
await untilAborted(ctx);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const chatModule = createModule("chat", (module) => {
|
|
65
|
+
// module.run — runs askBot as a child task of the module, so an unmount aborts it
|
|
66
|
+
const answer = (text) => module.run(askBot, text);
|
|
67
|
+
// on + queue — answer the texts one after another, in the order they were sent
|
|
68
|
+
module.on(messageSent, queue(answer));
|
|
69
|
+
// spawn — a background task that lives as long as the module
|
|
70
|
+
module.spawn(watchOperator);
|
|
71
|
+
module.spawn(listenToEscape);
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Three details that make the cleanup complete:
|
|
76
|
+
|
|
77
|
+
- **`module.run`, not `askBot` directly.** A handler given to `module.on` runs as a child of whoever emitted the event (here the panel), so unmounting stops listening but does not abort a request that is already running. Running it with `module.run` makes it a child of the module.
|
|
78
|
+
- **`queue`, not `latest`.** `queue` keeps the order when the user sends two texts fast. [`latest`](policies.md) would abort the first answer, which is wrong for a conversation.
|
|
79
|
+
- **A DOM listener with `signal`.** `addEventListener` takes a `signal` option and removes the listener when it aborts. The task stays pending until then, because a task that has finished is not aborted again.
|
|
80
|
+
|
|
81
|
+
## Open and close from the launcher
|
|
82
|
+
|
|
83
|
+
`app.mount` returns the function that unmounts the module. The launcher puts it together with the one that removes the panel.
|
|
84
|
+
|
|
85
|
+
<!-- docs-check: skip - the other files of the example are in examples/chat-widget -->
|
|
86
|
+
```js
|
|
87
|
+
// main.js
|
|
88
|
+
import { app, bus } from "./bus";
|
|
89
|
+
import { closeRequested } from "./chat";
|
|
90
|
+
import { chatModule } from "./chat-module";
|
|
91
|
+
import { renderPanel } from "./panel";
|
|
92
|
+
|
|
93
|
+
const button = document.querySelector("#chat-launcher");
|
|
94
|
+
let close = null; // set while the chat is open
|
|
95
|
+
|
|
96
|
+
const open = () => {
|
|
97
|
+
const stopModule = app.mount(chatModule); // subscriptions, polling and Escape start
|
|
98
|
+
const stopPanel = renderPanel(document.body);
|
|
99
|
+
close = () => {
|
|
100
|
+
stopPanel();
|
|
101
|
+
stopModule(); // everything the module started ends, a running request included
|
|
102
|
+
close = null;
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
button.addEventListener("click", () => {
|
|
107
|
+
if (close) {
|
|
108
|
+
close();
|
|
109
|
+
} else {
|
|
110
|
+
open();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// the launcher stays subscribed: it must hear Escape to close the chat
|
|
115
|
+
bus.on(closeRequested, () => close?.());
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The launcher's own `bus.on` is the only subscription that lives while the chat is closed, and it does nothing then: `close` is `null`.
|
|
119
|
+
|
|
120
|
+
## Check that closing cleans up
|
|
121
|
+
|
|
122
|
+
1. Open the chat and send a text. The bot answers after about a second. Every third question fails, and the retry asks again.
|
|
123
|
+
2. Send a text and close the chat before the answer arrives. No line appears, also when you open the chat again.
|
|
124
|
+
3. Look at the box "Listeners on the bus": it shows `bus.get(event).length` for four events and updates when the chat opens or closes. Closed: only `chat/closeRequested` has a listener (the launcher). Open: `chat/sent`, `chat/messageAdded` and `chat/typing` have one each as well. That is the proof that nothing is left subscribed.
|
|
125
|
+
4. Wait 10 s with the chat closed. The operator is not polled: no line from the operator appears.
|
|
126
|
+
5. Press `Escape` with the chat closed. Nothing happens. Open the chat and press it again: it closes.
|
|
127
|
+
6. Open the chat again. The old lines are back, and the polling runs.
|
|
128
|
+
|
|
129
|
+
`src/tests/chat-widget-example.test.ts` checks the same points without a browser.
|
|
130
|
+
|
|
131
|
+
## Where to go next
|
|
132
|
+
|
|
133
|
+
- Show an unread count on the launcher while the chat is closed: a second module that stays mounted and listens only to `chat/messageAdded`.
|
|
134
|
+
- Replace the polling by a WebSocket in a spawned task: the abort closes it the same way (see [`examples/trades`](../examples/trades) for a socket under `retry` and `latest`).
|
|
135
|
+
- Watch the whole thing in the [trace](tracing.md): the module, the bot task, the retries and the polling are tasks of one tree.
|
package/docs/context.md
CHANGED
|
@@ -15,7 +15,7 @@ bus.on("save", (draft, { store }) => {
|
|
|
15
15
|
});
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
That is enough in JavaScript. The fields appear on handlers, `ctx.run`, `ctx.on`,
|
|
18
|
+
That is enough in JavaScript. The fields appear on handlers, `ctx.run`, `ctx.on`, actions, modules, and child tasks of effects and policies.
|
|
19
19
|
|
|
20
20
|
Library fields (`signal`, `emit`, `run`, …) win on name clashes. `context` may be a function `(task) => fields` when the value depends on the task.
|
|
21
21
|
|
package/docs/errors.md
CHANGED
|
@@ -21,7 +21,7 @@ const bus = createBus({
|
|
|
21
21
|
|
|
22
22
|
## Warn when nobody listens
|
|
23
23
|
|
|
24
|
-
A typo in an event name (`emit("serch")`) does nothing and says nothing. `onUnhandled` runs when an event is emitted and nobody listens (a listener of every event, `"*"`, counts). It is not called for
|
|
24
|
+
A typo in an event name (`emit("serch")`) does nothing and says nothing. `onUnhandled` runs when an event is emitted and nobody listens (a listener of every event, `"*"`, counts). It is not called for actions, which always have a handler to run.
|
|
25
25
|
|
|
26
26
|
Use it as a warning in development:
|
|
27
27
|
|