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.
Files changed (67) hide show
  1. package/README.md +85 -30
  2. package/dist/{action-creator-Bpa8BUS9.d.ts → action-creator-CcAEvUnw.d.ts} +90 -23
  3. package/dist/actions.d.ts +54 -21
  4. package/dist/actions.js +1 -1
  5. package/dist/bus-DlpEluzd.js +1 -0
  6. package/dist/{context-types-BKIH5a8s.d.ts → context-types-hCKgU8sC.d.ts} +1 -1
  7. package/dist/context.d.ts +2 -2
  8. package/dist/dev.d.ts +2 -2
  9. package/dist/dev.js +1 -1
  10. package/dist/effects.d.ts +2 -2
  11. package/dist/event-DVcW0mME.js +1 -0
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.js +1 -1
  14. package/dist/match.d.ts +1 -1
  15. package/dist/{module-B6rg5NIb.d.ts → module-C_oEXw1n.d.ts} +40 -57
  16. package/dist/notrace/{action-creator-D2CotugT.d.ts → action-creator-DxOvVo8I.d.ts} +90 -23
  17. package/dist/notrace/actions.d.ts +114 -98
  18. package/dist/notrace/actions.js +1 -1
  19. package/dist/notrace/bus-DmKn4SDD.js +1 -0
  20. package/dist/notrace/effects.d.ts +2 -2
  21. package/dist/notrace/index.d.ts +2 -2
  22. package/dist/notrace/index.js +1 -1
  23. package/dist/notrace/{plugin-Dz6hq8Jg.d.ts → plugin-lJjjd-SN.d.ts} +1 -1
  24. package/dist/notrace/policies.d.ts +1 -1
  25. package/dist/notrace/policies.js +1 -1
  26. package/dist/notrace/redux.d.ts +1 -1
  27. package/dist/observable.d.ts +6 -6
  28. package/dist/oop.d.ts +2 -2
  29. package/dist/oop.js +1 -1
  30. package/dist/{plugin-D-j1DarG.d.ts → plugin-CuvIgjnX.d.ts} +2 -2
  31. package/dist/policies.d.ts +2 -2
  32. package/dist/react.d.ts +2 -2
  33. package/dist/redux.d.ts +1 -1
  34. package/dist/scope.d.ts +2 -2
  35. package/dist/signal.d.ts +1 -1
  36. package/dist/signal.js +1 -1
  37. package/dist/svelte.d.ts +1 -1
  38. package/dist/trace.d.ts +1 -1
  39. package/dist/{types-Bkuhnkq6.d.ts → types-FV8fuvI9.d.ts} +2 -2
  40. package/dist/vue.d.ts +1 -1
  41. package/dist/{with-policy-BeH8wCkX.d.ts → with-policy-3bhFLEXs.d.ts} +1 -1
  42. package/docs/README.md +5 -4
  43. package/docs/actions.md +94 -53
  44. package/docs/chat-example.md +8 -8
  45. package/docs/chat-widget.md +135 -0
  46. package/docs/context.md +1 -1
  47. package/docs/errors.md +1 -1
  48. package/docs/guide.md +26 -26
  49. package/docs/hmr.md +1 -1
  50. package/docs/motivation.md +10 -4
  51. package/docs/oop.md +1 -1
  52. package/docs/package.md +2 -2
  53. package/docs/plugins.md +23 -23
  54. package/docs/query-libraries.md +14 -14
  55. package/docs/scope.md +22 -22
  56. package/docs/server.md +12 -12
  57. package/docs/stability.md +2 -2
  58. package/docs/starter.md +3 -3
  59. package/docs/tracing.md +5 -5
  60. package/docs/typescript.md +16 -16
  61. package/llms-full.txt +380 -194
  62. package/llms.txt +3 -2
  63. package/package.json +5 -4
  64. package/dist/bus-hHp-T7JV.js +0 -1
  65. package/dist/event-0RIBGYqS.js +0 -1
  66. package/dist/notrace/bus-BdyvumpX.js +0 -1
  67. 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 → commands and modules for domain logic off the UI.
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 Redux, Effector or RxJS (SDKs, shared libraries, Module Federation): [Why nanoeffects exists](motivation.md).
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, commands, modules](actions.md) |
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, commands, jobs and modules](actions.md)
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, commands, jobs and modules
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 command
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 { createCommand } from "nanoeffects/actions";
29
+ import { createAction } from "nanoeffects/actions";
28
30
 
29
- // createCommand — emit runs the handler and returns its result
30
- export const double = createCommand("math/double", (value) => value * 2);
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, commands, and jobs
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, createCommand, createJob } from "nanoeffects/actions";
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
- // createCommand with a handler — emit returns what the handler returns
53
- export const getUser = createCommand("user/get", (id, { signal }) => {
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
- // createCommand without a handler — a contract; register the work elsewhere
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, prepare)` — build the payload from several arguments
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
- ### Commands
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
- Calling a command builds `{ type, payload }` like `createAction`. `bus.emit(command(payload))` (or `bus.emit(command, payload)`) tells the listeners of the command’s type, then runs its handler as a task and returns the result.
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
- - Wrap the handler in a policy: `createCommand("search", latest(fn))`.
84
- - `command.match(action)` narrows like an action creator’s `match`.
86
+ ### Bind a handler later
85
87
 
86
- **Where handlers are registered.** The command may carry a handler. The bus can also take handlers from a registry (`commandsPlugin`). A handler bound there wins over the command’s own. Each bus (each test) has its own registry.
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 { bindHandler, commandsPlugin } from "nanoeffects/actions";
92
- import { sendMail } from "./user";
103
+ import { actionsPlugin, setHandler } from "nanoeffects/actions";
104
+ import { sendMail } from "./mail";
93
105
 
94
106
  export const bus = createBus();
95
107
 
96
- // commandsPlugin — registry of handlers for this bus
97
- export const commands = bus.use(
98
- commandsPlugin([bindHandler(sendMail, smtpSend)]),
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 { commands } from "./bus";
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 = commands.handle(getUser, async () => fakeUser);
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 command with no handler anywhere throws `No handler for command "..."` from `emit` (the call itself only builds the action).
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
- The registry lives in `nanoeffects/actions`, so a bus without one carries none of that code. Without a registry, `emit` runs the command’s own handler. `bus.use(commandsPlugin())` sets `bus.commands`. A second `commandsPlugin` binds its list in that registry and returns it. There is no `commands` option in `createBus`; assign a custom runner with `bus.commands = { run }`.
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 command that also sends its outcome as actions, when nobody waits for the result. It returns the command with `loadUser.pending`, `loadUser.success`, `loadUser.fail` and `loadUser.cancel` on it.
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, use a command.
125
- - Outcome actions are emitted as the task that ran the job (also for a handler bound with `commands.handle`). `success` and `fail` are not sent after abort; `cancel` is, at the moment of the abort.
126
- - A missing handler is not an outcome: `emit` throws `No handler for command "..."` and nothing is sent.
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 command that also sends pending, success, fail and cancel
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 command bindings. With plain `bus.on` / `commands.handle` you must remember every unsubscribe.
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`, `commands`, and `createModule`: the same as `createModule` below, with the context fields of the bus typed (see [TypeScript](typescript.md)).
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 { createCommand } from "nanoeffects/actions";
254
+ import { createAction } from "nanoeffects/actions";
214
255
 
215
- export const getUser = createCommand("user/get");
216
- export const saveUser = createCommand("user/save", persistUser);
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.commands.handle(getUser, fakeUser); // in a test
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 commands, sub-modules, and background tasks (aborted).
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(commandsPlugin())` ran first, that registry is used; otherwise the module plugin attaches its own. Either way it is `bus.commands` and `app.commands`.
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 `commands`). 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).
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 a command a handler (contract, or override).
292
- - `use` — bring sub-modules and commands that carry a handler; the returned function undoes that call.
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
- - A command 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.
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 a command, an action or a module its own fields
337
+ ## Give an action or a module its own fields
297
338
 
298
- A command 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 `createCommand`, `createAction`, `createModule` and `createJob`, and turn it on with `scopePlugin`:
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 a command work on this bus
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 { createCommand } from "nanoeffects/actions";
352
+ import { createAction } from "nanoeffects/actions";
312
353
 
313
- // the third argument — fields of this command only
314
- export const getUser = createCommand(
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 },
@@ -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, commands, 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.
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 events and commands, never through its handlers or its store. Events and commands both live in the events file. An event (`createAction`) says that something happened, and any feature may listen: `rooms/selected` is heard by two. A command (`createCommand`) is an order with exactly one handler, as `search/openResult` and `messages/mark` are. The handler is given to `createCommand`, so the command 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 a command 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.
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 command that also sends pending, success, fail and cancel
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 { createCommand } from "nanoeffects/actions";
182
+ import { createAction } from "nanoeffects/actions";
183
183
  import { take } from "nanoeffects/effects";
184
184
 
185
- // createCommand — an order for the message entity, with the handler that does it
186
- const markMessage = createCommand("messages/mark", (messageId) => console.log("mark", messageId));
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 command — the message entity marks the message
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 commands, jobs and modules: [Actions, commands, jobs and modules](actions.md).
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`, commands, modules, and child tasks of effects and policies.
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 commands, which always have a handler to run.
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