nanoeffects 0.4.0 → 0.6.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 (60) hide show
  1. package/README.md +4 -1
  2. package/dist/{action-creator-D48fp13T.d.ts → action-creator-c292nK7v.d.ts} +130 -34
  3. package/dist/actions.d.ts +54 -5
  4. package/dist/actions.js +1 -1
  5. package/dist/{bus-Cho463Sq.js → bus-BAs9x7IQ.js} +1 -1
  6. package/dist/{context-DlA80GOA.js → context-CK0ZomWt.js} +1 -1
  7. package/dist/context-plugin-1pYmSQEG.d.ts +34 -0
  8. package/dist/dev.d.ts +2 -2
  9. package/dist/effects.d.ts +27 -129
  10. package/dist/effects.js +1 -1
  11. package/dist/index.d.ts +3 -2
  12. package/dist/index.js +1 -1
  13. package/dist/{module-DVX8aYlq.d.ts → module-BlF2KSgy.d.ts} +34 -5
  14. package/dist/notrace/{action-creator-D48fp13T.d.ts → action-creator-DKG_sIia.d.ts} +130 -34
  15. package/dist/notrace/actions.d.ts +96 -7
  16. package/dist/notrace/actions.js +1 -1
  17. package/dist/notrace/{bus-B12SvxLv.js → bus-Bdk0YyFQ.js} +1 -1
  18. package/dist/notrace/{context-yAWj0BFZ.js → context-CctVmHLC.js} +1 -1
  19. package/dist/notrace/context-plugin-Dt1IgVo8.d.ts +34 -0
  20. package/dist/notrace/{delay-Cgvn16oY.js → delay-B_W1tnkS.js} +1 -1
  21. package/dist/notrace/effects.d.ts +27 -129
  22. package/dist/notrace/effects.js +1 -1
  23. package/dist/notrace/index.d.ts +3 -2
  24. package/dist/notrace/index.js +1 -1
  25. package/dist/notrace/plugin-B0AXKusc.d.ts +207 -0
  26. package/dist/notrace/policies.d.ts +1 -1
  27. package/dist/notrace/policies.js +1 -1
  28. package/dist/notrace/redux.d.ts +18 -20
  29. package/dist/notrace/redux.js +1 -1
  30. package/dist/observable.d.ts +1 -1
  31. package/dist/plugin-CCYlC0XS.d.ts +207 -0
  32. package/dist/policies.d.ts +1 -1
  33. package/dist/policies.js +1 -1
  34. package/dist/react.d.ts +50 -0
  35. package/dist/react.js +1 -0
  36. package/dist/redux.d.ts +18 -20
  37. package/dist/redux.js +1 -1
  38. package/dist/scope-key-B7l7mUS5.js +1 -0
  39. package/dist/scope.d.ts +58 -0
  40. package/dist/scope.js +1 -0
  41. package/dist/trace.d.ts +1 -1
  42. package/dist/types-mLaAx-EZ.d.ts +15 -0
  43. package/docs/README.md +7 -1
  44. package/docs/actions.md +25 -1
  45. package/docs/context.md +26 -2
  46. package/docs/effects.md +83 -5
  47. package/docs/guide.md +24 -0
  48. package/docs/migrating-from-effector.md +293 -0
  49. package/docs/migrating-from-rxjs.md +127 -0
  50. package/docs/migrating-from-saga.md +93 -0
  51. package/docs/observable.md +1 -0
  52. package/docs/package.md +13 -2
  53. package/docs/policies.md +1 -0
  54. package/docs/react.md +157 -0
  55. package/docs/scope.md +226 -0
  56. package/docs/stores.md +429 -40
  57. package/docs/typescript.md +153 -0
  58. package/llms-full.txt +2925 -0
  59. package/llms.txt +49 -0
  60. package/package.json +44 -6
package/docs/package.md CHANGED
@@ -6,9 +6,20 @@ Imports are split so you only load what you use. `createBus` stays small; the re
6
6
  | ------ | ------------- |
7
7
  | `nanoeffects` | `createBus`, the types |
8
8
  | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
9
- | `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, … |
10
- | `nanoeffects/actions` | `createAction`, `createCommand`, `createJob`, `createModule`, `modulePlugin` |
9
+ | `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
10
+ | `nanoeffects/actions` | `createAction`, `createCommand`, `createJob`, `createModule`, `modulePlugin`, `commandsPlugin` |
11
+ | `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one command, action, job, module or `emit` (the types `Scope`, `Effect`) |
11
12
  | `nanoeffects/redux` | `createEffectsMiddleware` |
12
13
  | `nanoeffects/observable` | `createObserve` |
14
+ | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
13
15
  | `nanoeffects/dev` | `devPlugin` (no-op in production) |
14
16
  | `nanoeffects/trace` | `createTracer` and the loggers |
17
+
18
+ ## Give the docs to an AI assistant
19
+
20
+ The package ships two plain-text files for assistants that write code with it:
21
+
22
+ - `llms.txt`: a short index. It states the rules that generated code often gets wrong and links to every page of these docs.
23
+ - `llms-full.txt`: the same summary followed by the text of every linked page, in one file.
24
+
25
+ Find them in `node_modules/nanoeffects/`, or load `llms-full.txt` from a CDN such as `https://unpkg.com/nanoeffects/llms-full.txt`. Both match the installed version.
package/docs/policies.md CHANGED
@@ -16,6 +16,7 @@ const bus = createBus();
16
16
  bus.on(
17
17
  "search",
18
18
  latest(async (query, { signal }) => {
19
+ // signal — lets latest cancel this request, not only drop its result
19
20
  await api.search(query, { signal });
20
21
  }),
21
22
  );
package/docs/react.md ADDED
@@ -0,0 +1,157 @@
1
+ # Build a sign-up form in React
2
+
3
+ Use this page when a React screen starts requests on user input and you need them not to race: a username check while typing, a submit button, a flaky network. It builds one registration form. The bus lives outside React; the component only subscribes to it.
4
+
5
+ You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
6
+
7
+ ## See what goes wrong without a bus
8
+
9
+ A registration form has three problems that `useEffect` and `fetch` leave to you:
10
+
11
+ - **A stale answer.** The user types `ann`, then `anna`. The answer for `ann` arrives last and says the name is free, although `anna` is taken.
12
+ - **A double submit.** A second click on "Sign up" sends a second request while the first one is running.
13
+ - **A flaky network.** One failed request should be retried, but a "name is taken" answer should not.
14
+
15
+ Each has a policy or an effect: `latest`, `leading`, `retry`. They live in one place, outside the component.
16
+
17
+ ## Wire the bus once
18
+
19
+ Put the bus and its handlers in their own file. Create it once, outside any component: a bus created in the body of a component would be a new bus, without subscribers, on every render.
20
+
21
+ ```js
22
+ // registration.js
23
+ import { createBus } from "nanoeffects";
24
+ import { retry } from "nanoeffects/effects";
25
+ import { debounce, latest, leading } from "nanoeffects/policies";
26
+
27
+ // fetch rejects with a TypeError when the network fails; an HTTP error is a plain Error
28
+ const isNetworkError = (error) => error instanceof TypeError;
29
+
30
+ export const bus = createBus(); // the bus: created once, at the top of the module
31
+
32
+ // debounce — wait 300 ms of quiet; latest — abort the check of the previous name
33
+ bus.on(
34
+ "username/typed",
35
+ debounce(
36
+ 300,
37
+ latest(async (name, ctx) => {
38
+ // api — your client; pass ctx.signal so latest can cancel the request itself
39
+ const available = await api.usernameAvailable(name, { signal: ctx.signal });
40
+ ctx.emit("username/checked", { name, available });
41
+ }),
42
+ ),
43
+ );
44
+
45
+ // leading — a second click is ignored while the first sign up is running
46
+ bus.on(
47
+ "form/submitted",
48
+ leading(async (form, ctx) => {
49
+ const signUp = () => api.signUp(form, { signal: ctx.signal });
50
+ try {
51
+ // retry — up to 3 tries, 500 ms apart, and only when the network failed
52
+ const user = await retry(ctx, signUp, {
53
+ times: 3,
54
+ delay: 500,
55
+ shouldRetry: isNetworkError,
56
+ });
57
+ ctx.emit("form/succeeded", user);
58
+ } catch (error) {
59
+ ctx.emit("form/failed", error.message);
60
+ }
61
+ }),
62
+ );
63
+ ```
64
+
65
+ The handlers know nothing about React. A test can emit `username/typed` and read `username/checked` without rendering anything: the same wiring is tested in [`examples/registration-form.ts`](../examples/registration-form.ts) and `src/tests/registration-form.test.ts`.
66
+
67
+ ## Connect the form
68
+
69
+ The component keeps what it shows in `useState`. It subscribes in `useEffect` and emits events from its handlers. `bus.on` returns the function that unsubscribes; returning it from the effect removes the listener on unmount.
70
+
71
+ ```jsx
72
+ // SignUpForm.jsx
73
+ import { useEffect, useState } from "react";
74
+ import { bus } from "./registration"; // the bus of the previous snippet
75
+
76
+ export function SignUpForm() {
77
+ const [name, setName] = useState("");
78
+ const [password, setPassword] = useState("");
79
+ const [available, setAvailable] = useState(null); // null: not checked yet
80
+ const [status, setStatus] = useState("idle"); // idle, sending, done or failed
81
+ const [error, setError] = useState("");
82
+
83
+ useEffect(() => {
84
+ // bus.on — listen for what the handlers report; each call returns an unsubscribe function
85
+ const unbindChecked = bus.on("username/checked", (result) => {
86
+ setAvailable(result.available);
87
+ });
88
+ const unbindSucceeded = bus.on("form/succeeded", () => setStatus("done"));
89
+ const unbindFailed = bus.on("form/failed", (message) => {
90
+ setStatus("failed");
91
+ setError(message);
92
+ });
93
+
94
+ return () => {
95
+ unbindChecked();
96
+ unbindSucceeded();
97
+ unbindFailed();
98
+ };
99
+ }, []);
100
+
101
+ function handleName(event) {
102
+ const value = event.target.value;
103
+ setName(value);
104
+ setAvailable(null);
105
+ bus.emit("username/typed", value); // bus.emit — the handler debounces and checks the name
106
+ }
107
+
108
+ function handleSubmit(event) {
109
+ event.preventDefault();
110
+ setStatus("sending");
111
+ bus.emit("form/submitted", { name, password }); // a second click is dropped by leading
112
+ }
113
+
114
+ return (
115
+ <form onSubmit={handleSubmit}>
116
+ <input value={name} onChange={handleName} placeholder="Username" />
117
+ {available === false && <p>This name is taken</p>}
118
+ <input
119
+ type="password"
120
+ value={password}
121
+ onChange={(event) => setPassword(event.target.value)}
122
+ />
123
+ <button type="submit" disabled={status === "sending"}>Sign up</button>
124
+ {status === "failed" && <p>{error}</p>}
125
+ {status === "done" && <p>Welcome!</p>}
126
+ </form>
127
+ );
128
+ }
129
+ ```
130
+
131
+ The `disabled` button is only for the look. The form stays correct without it: `leading` drops a second submit while the first one runs.
132
+
133
+ In development, React StrictMode runs the effect twice (subscribe, unsubscribe, subscribe). That is safe here, because the cleanup removes exactly what the effect added.
134
+
135
+ ## Stop running work when the form closes
136
+
137
+ Unsubscribing stops the component from hearing results, but a request that has started keeps running. To cancel it, the component emits `form/closed` when it unmounts, and each handler that should stop listens for it with `ctx.on`:
138
+
139
+ ```js
140
+ import { latest } from "nanoeffects/policies";
141
+
142
+ // the username check of registration.js, with one new line
143
+ const checkName = latest(async (name, ctx) => {
144
+ // ctx.on — a subscription of this task; aborting the task also drops the result
145
+ ctx.on("form/closed", () => ctx.abort());
146
+ const available = await api.usernameAvailable(name, { signal: ctx.signal });
147
+ ctx.emit("username/checked", { name, available });
148
+ });
149
+ ```
150
+
151
+ In the component, add `bus.emit("form/closed")` to the cleanup function of the effect. This is optional: skip it when finishing a request after unmount does no harm, as for a cheap check.
152
+
153
+ ## Where to go next
154
+
155
+ - The policies behind this form: [Policies](policies.md).
156
+ - `retry`, `timeout` and the other effects: [Effects](effects.md).
157
+ - A state library instead of `useState`: [Stores](stores.md).
package/docs/scope.md ADDED
@@ -0,0 +1,226 @@
1
+ # Pin context fields to a command, an action, a module or a call
2
+
3
+ Optional, for domain logic. Use it when one command needs its own client, one module its own logger, or one test its own fake, and a field on the whole bus ([context fields](context.md)) is too wide.
4
+
5
+ A **scope** is a plain object. Every key goes on `ctx` as it is (`cache`, `api`, `logger`), except one: `effects`. Its functions take `{ signal }` as the first parameter, like `retry(ctx, fn)`, and go on `ctx` bound to the task, so you call `ctx.fetchJson(url)` instead of `fetchJson(ctx, url)`.
6
+
7
+ You give a scope to the thing that uses it. A call can replace it. Nothing else changes: handlers still get `(payload, ctx)`.
8
+
9
+ ## Turn scopes on
10
+
11
+ Scopes are a plugin, so a bus that does not use them pays nothing.
12
+
13
+ ```js
14
+ import { createBus } from "nanoeffects";
15
+ import { scopePlugin } from "nanoeffects/scope";
16
+
17
+ // scopePlugin — makes the scopes below work on this bus; returns the same bus
18
+ const bus = createBus().use(scopePlugin());
19
+ ```
20
+
21
+ - Without `scopePlugin` a scope is **ignored**: no error and no warning. If a field is missing from `ctx`, check this first.
22
+ - Use it **after** `effectsPlugin` (an effect it adds later would be assigned over an effect of a scope with the same name) and **before** `modulePlugin` (it mounts the modules when it is used). `contextPlugin` and `createBus({ context })` can come in any order: a scope wins over them.
23
+
24
+ ## Give a command its own fields
25
+
26
+ ```js
27
+ import { createBus } from "nanoeffects";
28
+ import { createCommand } from "nanoeffects/actions";
29
+ import { scopePlugin } from "nanoeffects/scope";
30
+
31
+ const bus = createBus().use(scopePlugin());
32
+
33
+ // createCommand — third argument is the scope of this command
34
+ const getUser = createCommand(
35
+ "user/get",
36
+ (id, { api, cache }) => {
37
+ // api and cache are on ctx for this command only
38
+ return cache.get(id) ?? api.getUser(id);
39
+ },
40
+ { api, cache: new Map() },
41
+ );
42
+
43
+ const user = await bus.emit(getUser("1")); // the handler has api and cache
44
+ ```
45
+
46
+ The listeners of `"user/get"` get the same fields when the command is emitted, because the fields belong to the call.
47
+
48
+ ## Bind an effect to the task
49
+
50
+ An effect is a function with `{ signal }` as its first parameter. In `effects` it is bound to the task, so it stops with the task.
51
+
52
+ ```js
53
+ import { createBus } from "nanoeffects";
54
+ import { createCommand } from "nanoeffects/actions";
55
+ import { delay, race } from "nanoeffects/effects";
56
+ import { scopePlugin } from "nanoeffects/scope";
57
+
58
+ const bus = createBus().use(scopePlugin());
59
+
60
+ // your own effect: first parameter is the task (only its signal is used)
61
+ const fetchJson = (ctx, url) =>
62
+ fetch(url, { signal: ctx.signal }).then((response) => response.json());
63
+
64
+ const loadFeed = createCommand(
65
+ "feed/load",
66
+ (url, { fetchJson, race, delay }) =>
67
+ // race, delay, fetchJson — bound to this task: no ctx argument
68
+ race({
69
+ feed: () => fetchJson(url),
70
+ timeout: () => delay(5000),
71
+ }),
72
+ // effects — built-in and your own functions share one object
73
+ { effects: { fetchJson, race, delay } },
74
+ );
75
+
76
+ const result = await bus.emit(loadFeed("/feed"));
77
+ ```
78
+
79
+ - Put in `effects` only functions that take `ctx` first. A plain function (a formatter, a logger) belongs in `context`.
80
+ - A function under any other key is copied as it is; a function in `effects` is bound.
81
+ - `effects` is the one name a field of the scope cannot have.
82
+
83
+ ## Replace a field for one call
84
+
85
+ The second argument of `emit` is a scope for the tasks of **that** call. It is the usual way to give a test a fake.
86
+
87
+ ```js
88
+ import { createBus } from "nanoeffects";
89
+ import { createCommand } from "nanoeffects/actions";
90
+ import { scopePlugin } from "nanoeffects/scope";
91
+
92
+ const bus = createBus().use(scopePlugin());
93
+ const getUser = createCommand("user/get", (id, { api }) => api.getUser(id), {
94
+ api,
95
+ });
96
+
97
+ // the real api for this call
98
+ await bus.emit(getUser("1"));
99
+
100
+ // the second argument — a fake api, for this call only
101
+ const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
102
+ await bus.emit(getUser("1"), { api: fakeApi });
103
+ ```
104
+
105
+ Rules for the second argument:
106
+
107
+ - It works with an action or a command call (`emit(getUser("1"), scope)`, `ctx.emit(loaded(user), scope)`).
108
+ - It does not work with the string form: in `emit("user/get", payload)` the second argument is the payload.
109
+ - It can replace `effects` too: `{ effects: { delay: instantDelay } }`.
110
+ - Fields of the library (`signal`, `emit`, …) in it are ignored.
111
+
112
+ ## Give an action, a job or a module its own fields
113
+
114
+ ```js
115
+ import { createAction, createJob, createModule } from "nanoeffects/actions";
116
+
117
+ // createAction — the listeners of this action's calls get the fields
118
+ const userLoaded = createAction("user/loaded", { store });
119
+
120
+ // createJob — the scope is the fourth argument, after the outcomes;
121
+ // the handler and the listeners of the outcome get its fields
122
+ const loadUser = createJob(
123
+ "user/load",
124
+ fetchUser,
125
+ { success: userLoaded, fail: "user/failed" },
126
+ { logger },
127
+ );
128
+
129
+ // createModule — third argument; everything the module owns gets the fields:
130
+ // m.on listeners, m.handle handlers, m.spawn tasks
131
+ const auditModule = createModule(
132
+ "audit",
133
+ (m) => {
134
+ m.on(userLoaded, (user, { logger }) => logger.info(user.id));
135
+ },
136
+ { logger },
137
+ );
138
+ ```
139
+
140
+ A module scope does not leak: a listener registered outside the module does not see it.
141
+
142
+ ## Which value wins
143
+
144
+ When several places set the same field, the strongest one wins:
145
+
146
+ | Strength | Where it is set |
147
+ | -------- | --------------- |
148
+ | 1 (strongest) | the second argument of `emit` |
149
+ | 2 | the scope of the action, command or job |
150
+ | 3 | the scope of the module that owns the handler |
151
+ | 4 (weakest) | the bus: `createBus({ context })`, `contextPlugin`, `effectsPlugin` |
152
+
153
+ The fields of the library (`signal`, `abort`, `emit`, `on`, `off`, `run`, `type`, `payload`) are never replaced. A child task (`ctx.run`, an attempt of `retry`) has the fields of its parent.
154
+
155
+ ## Keep a generic effect generic
156
+
157
+ In `effects` a function loses its first parameter, and TypeScript cannot keep a generic through that step: `<T>(items: T[]) => T` becomes `(items: unknown[]) => unknown`. JavaScript code is not affected. If you want the exact type, use one of two ways.
158
+
159
+ `effect(fn)`: mark the function where you declare it, then put it in the scope like any other value. No annotation, so a JavaScript editor shows the exact hint too.
160
+
161
+ ```js
162
+ import { createCommand } from "nanoeffects/actions";
163
+ import { effect } from "nanoeffects/scope";
164
+
165
+ // effect — marks the function, so a scope binds it to the task; the editor keeps its generic
166
+ const first = effect((ctx, items) => items[0]);
167
+
168
+ const pick = createCommand("pick", (items, { first }) => first(items), {
169
+ first, // a plain key of the scope, not an entry of effects
170
+ });
171
+ ```
172
+
173
+ Call `effect` where you declare the function, as above, and not inline in the scope: TypeScript does not infer the scope from a generic call written there.
174
+
175
+ `Effect<F>` (TypeScript): declare how the effect looks **after** it is bound, and keep it in `effects`.
176
+
177
+ ```ts
178
+ import { createCommand } from "nanoeffects/actions";
179
+ import type { Effect } from "nanoeffects/effects";
180
+
181
+ // First — checker-only: how a handler calls the effect (no ctx)
182
+ type First = <T>(items: T[]) => T;
183
+
184
+ // Effect<First> — the function takes the task first and is typed as First once bound
185
+ const first: Effect<First> = (ctx, items) => items[0];
186
+
187
+ const pick = createCommand(
188
+ "pick",
189
+ (id: string, { first }) => first([id]), // string
190
+ { effects: { first } },
191
+ );
192
+ ```
193
+
194
+ `Effect<F>` and `effect` change what the checker sees. At run time the function is the same.
195
+
196
+ ## Type the fields (optional)
197
+
198
+ A handler of a command, job or module with a scope sees the fields of the scope in `ctx`, typed from the values you gave. You write no `WithContext`.
199
+
200
+ ```ts
201
+ import { createCommand } from "nanoeffects/actions";
202
+
203
+ type User = { id: string; name: string };
204
+
205
+ const getUser = createCommand(
206
+ "user/get",
207
+ async (id: string, ctx) => {
208
+ ctx.cache; // Map<string, User>, from the scope below
209
+ return ctx.cache.get(id);
210
+ },
211
+ { cache: new Map<string, User>() },
212
+ );
213
+ ```
214
+
215
+ - A field of a scope replaces a field of the same name from the bus in the type too.
216
+ - An override in `emit` must match the type of the field it replaces: `emit(getUser("1"), { cache: 1 })` is an error.
217
+ - A field that no scope and no bus declares has no type to check against, so `emit(call, { unknown: 1 })` is an error in TypeScript.
218
+ - `bus.on(creator, fn)` for an action with a scope types `ctx` with its fields.
219
+
220
+ ## Limits
221
+
222
+ - No plugin, no scope: see [Turn scopes on](#turn-scopes-on).
223
+ - The fields of the bus are not in the handler of a command made with `createCommand(…, scope)` unless you type `ctx` yourself; the fields of the scope are.
224
+ - The string form of `emit` takes no scope.
225
+ - `effect(fn)` works only as a value of a scope, not as an entry of `effects`.
226
+ - A scope on a command, action, job or module is typed without the events of a bus, so `take` in its `effects` takes any event name.