nanoeffects 0.3.0 → 0.5.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 +53 -55
  2. package/dist/{action-Bdfp-Wvd.d.ts → action-creator-DOWcbXpy.d.ts} +79 -73
  3. package/dist/actions.d.ts +75 -6
  4. package/dist/actions.js +1 -1
  5. package/dist/bus-BAs9x7IQ.js +1 -0
  6. package/dist/{context-DlA80GOA.js → context-CK0ZomWt.js} +1 -1
  7. package/dist/context-plugin-B0sLWOxR.d.ts +34 -0
  8. package/dist/dev.d.ts +2 -2
  9. package/dist/effects.d.ts +153 -56
  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-I1ixyJzx.d.ts → module-DCJl-qfi.d.ts} +24 -9
  14. package/dist/notrace/{action-Bdfp-Wvd.d.ts → action-creator-DOWcbXpy.d.ts} +79 -73
  15. package/dist/notrace/actions.d.ts +97 -13
  16. package/dist/notrace/actions.js +1 -1
  17. package/dist/notrace/bus-Bdk0YyFQ.js +1 -0
  18. package/dist/notrace/{context-yAWj0BFZ.js → context-CctVmHLC.js} +1 -1
  19. package/dist/notrace/context-plugin-B0sLWOxR.d.ts +34 -0
  20. package/dist/notrace/{delay-Cgvn16oY.js → delay-B_W1tnkS.js} +1 -1
  21. package/dist/notrace/effects.d.ts +153 -56
  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/policies.d.ts +1 -1
  26. package/dist/notrace/policies.js +1 -1
  27. package/dist/notrace/redux.d.ts +18 -20
  28. package/dist/notrace/redux.js +1 -1
  29. package/dist/observable.d.ts +1 -1
  30. package/dist/policies.d.ts +1 -1
  31. package/dist/policies.js +1 -1
  32. package/dist/react.d.ts +50 -0
  33. package/dist/react.js +1 -0
  34. package/dist/redux.d.ts +18 -20
  35. package/dist/redux.js +1 -1
  36. package/dist/trace.d.ts +1 -1
  37. package/docs/README.md +30 -9
  38. package/docs/actions.md +117 -41
  39. package/docs/context.md +48 -7
  40. package/docs/effects.md +229 -73
  41. package/docs/errors.md +23 -10
  42. package/docs/guide.md +238 -129
  43. package/docs/hmr.md +25 -13
  44. package/docs/migrating-from-effector.md +293 -0
  45. package/docs/migrating-from-rxjs.md +127 -0
  46. package/docs/migrating-from-saga.md +93 -0
  47. package/docs/observable.md +11 -9
  48. package/docs/package.md +24 -0
  49. package/docs/policies.md +93 -30
  50. package/docs/react.md +157 -0
  51. package/docs/semantics.md +5 -3
  52. package/docs/stores.md +488 -25
  53. package/docs/tracing.md +69 -54
  54. package/docs/typescript.md +153 -0
  55. package/llms-full.txt +2645 -0
  56. package/llms.txt +48 -0
  57. package/package.json +39 -9
  58. package/dist/bus-CRnlwmQ4.js +0 -1
  59. package/dist/notrace/bus-Dx0l9X6h.js +0 -1
  60. package/dist/notrace/event-0RIBGYqS.js +0 -1
package/docs/effects.md CHANGED
@@ -1,20 +1,199 @@
1
1
  # Effects and cancellation
2
2
 
3
- An abort only raises a flag: your code decides to stop. `nanoeffects/effects` has helpers for the usual cases.
3
+ Use this page for retry, timeouts, waiting, or parallel work — not as the first page for a small bus. Pick **one** helper for your job; the rest is reference.
4
4
 
5
- `delay(ctx, ms)` waits and rejects on abort (`ms` can be a function). `cancellable(ctx, executor)` is a promise tied to the task signal; it works like `new Promise`, but the executor gets one object, `{ resolve, reject, onCancel }`, with `onCancel` for cleanups:
5
+ An abort only raises a flag. Your code decides to stop. Start with `retry` / `timeout` on a handler (see [Guide §5](guide.md#5-add-retry-and-timeout)).
6
6
 
7
- ```ts
7
+ ## Wrap a handler with retry and timeout
8
+
9
+ `retry`, `timeout`, and `poll` have two forms:
10
+
11
+ - **Task wrapper** — take a function `(payload, ctx) => result` and return one. Pass them into `on` next to policies (no `ctx` in their arguments).
12
+ - **Call inside a body** — take `ctx` first.
13
+
14
+ The first argument tells them apart: a function (`retry`), a number (`timeout`), or a number/function (`poll`) is a wrapper; an object is `ctx`.
15
+
16
+ ```js
17
+ import { createBus } from "nanoeffects";
18
+ import { poll, retry, timeout } from "nanoeffects/effects";
19
+ import { latest } from "nanoeffects/policies";
20
+
21
+ const bus = createBus();
22
+ const loadUser = (id, { signal }) => fetchUser(id, { signal });
23
+
24
+ // timeout — one try must finish within 5 s
25
+ const loadOnce = timeout(5000, loadUser);
26
+ // retry — up to 4 attempts with backoff
27
+ const loadWithRetry = retry(loadOnce, {
28
+ times: 4,
29
+ delay: (attempt) => 2 ** attempt * 100,
30
+ shouldRetry: (error) => !isFatal(error),
31
+ });
32
+ // latest — a newer load aborts the older retry chain
33
+ bus.on("load", latest(loadWithRetry));
34
+
35
+ // poll — call refresh every 10 s until the task aborts
36
+ bus.on("watch", poll(10_000, (_, { signal }) => refresh({ signal })));
37
+
38
+ // ctx.run — run a wrapped Task as a child (aborted with the handler)
39
+ bus.on("open", async (id, ctx) => {
40
+ const load = retry(timeout(5000, loadUser));
41
+ const user = await ctx.run(load, id);
42
+ ctx.emit("opened", user);
43
+ });
44
+
45
+ // ctx-first form — one call inside a body
46
+ bus.on("load", async (id, ctx) => {
47
+ const user = await retry(
48
+ ctx,
49
+ () => fetchUser(id, { signal: ctx.signal }),
50
+ { times: 4 },
51
+ );
52
+ const data = await timeout(ctx, 5000, ({ signal }) => {
53
+ return fetchData(id, { signal });
54
+ });
55
+ await poll(ctx, () => refresh(), 10_000);
56
+ ctx.emit("ready", { user, data });
57
+ });
58
+ ```
59
+
60
+ Use the wrapper for a whole handler or a whole `ctx.run`. Use the `ctx` form for one call in the middle of a body.
61
+
62
+ Each attempt or call of a wrapper gets its own child context. Abort of the caller reaches it. Abort of one attempt (a timeout) does not reach the caller. The attempt number is passed only in the `ctx` form.
63
+
64
+ ### Options for retry, timeout, and poll
65
+
66
+ **`retry`**
67
+
68
+ - `times` — total attempts (default `3`).
69
+ - `delay` — number or `(attempt, error) => ms`.
70
+ - `shouldRetry(error, attempt)` — stop early.
71
+ - `fn` gets the attempt number starting at `1`.
72
+ - On abort, rejects with the abort reason.
73
+
74
+ **`timeout`**
75
+
76
+ - Error is a `DOMException` named `TimeoutError` (like `AbortSignal.timeout`).
77
+ - Rejects even if the starter ignores its signal.
78
+
79
+ **`poll`** (like patronum `interval`)
80
+
81
+ - First call after `ms`. `leading: true` also calls at once.
82
+ - Next ticks keep a steady rate whatever the duration of `fn`, so a slow `fn` can overlap the next call.
83
+ - `waitForFn: true` starts each pause when the call ends — calls never overlap.
84
+ - `fn` gets the call number from `0`.
85
+ - An error from `fn` stops polling and rejects. An abort ends it normally and clears the timer.
86
+
87
+ A pause can be a function wherever a time is given. For retries with a cap and jitter, see [Back off between retries](#back-off-between-retries):
88
+
89
+ ```js
90
+ import { createBus } from "nanoeffects";
91
+ import { delay, poll, retry } from "nanoeffects/effects";
92
+
93
+ const bus = createBus();
94
+
95
+ bus.on("sync", async (_, ctx) => {
96
+ retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, …
97
+ poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows to once a minute
98
+ await delay(ctx, () => 200 + Math.random() * 200); // 200–400 ms
99
+ });
100
+ ```
101
+
102
+ All three are traced: the effect is a group (`retry`, `timeout`, `poll`) and each attempt or call is a `run` child.
103
+
104
+ ## Back off between retries
105
+
106
+ Use this when a server or network fails under load. Retrying at once, or at the same fixed pause, sends every client back at the same moment and can keep the server down. Make the pause grow with each attempt, and add jitter so clients spread out.
107
+
108
+ The shortest form is a `delay` function. It needs no import:
109
+
110
+ ```js
111
+ import { retry } from "nanoeffects/effects";
112
+
113
+ const loadWithRetry = retry(loadUser, {
114
+ times: 5,
115
+ delay: (attempt) => 2 ** attempt * 100, // 200, 400, 800, 1600 ms
116
+ });
117
+ ```
118
+
119
+ When you also want a cap and jitter, build the function with `backoff`:
120
+
121
+ ```js
122
+ import { backoff, retry } from "nanoeffects/effects";
123
+
124
+ const loadWithRetry = retry(loadUser, {
125
+ times: 5,
126
+ // backoff — pause 200, 400, 800, … ms, never over 5 s, a random part of it
127
+ delay: backoff({ base: 200, max: 5000, jitter: true }),
128
+ });
129
+ ```
130
+
131
+ | Option | Meaning |
132
+ | ------ | ------- |
133
+ | `base` | pause before the second attempt, in ms (default `100`) |
134
+ | `factor` | each pause is this many times longer (default `2`) |
135
+ | `max` | longest pause, in ms (default none) |
136
+ | `jitter` | `true` picks a random pause from 0 up to the computed one (default `false`) |
137
+
138
+ - With `jitter: true` a pause can be close to 0, so treat `base` as a scale, not a minimum.
139
+ - `retry` counts attempts from 1. `poll` counts calls from 0, so with `poll` the first pause is `base / factor`.
140
+ - The result is a plain function `(attempt) => ms`, so it also fits `delay(ctx, …)`.
141
+
142
+ ## Put selected effects on `ctx`
143
+
144
+ If handlers call the same helpers often, put them on the task once. A handler then destructures them like `signal`, with no `ctx` argument. Only the effects you pass are imported, so the rest stay out of the bundle ([package entries](package.md)).
145
+
146
+ ```js
147
+ import { createBus } from "nanoeffects";
148
+ import { all, delay, effectsPlugin, race } from "nanoeffects/effects";
149
+
150
+ // effectsPlugin — add race, all and delay to the context of every task; returns the same bus
151
+ const bus = createBus().use(effectsPlugin({ race, all, delay }));
152
+
153
+ bus.on("load", async (id, { race, all, delay }) => {
154
+ // race — same as race(ctx, …)
155
+ const result = await race({
156
+ data: ({ signal }) => fetchData(id, { signal }),
157
+ timeout: () => delay(5000), // delay — same as delay(ctx, 5000)
158
+ });
159
+ if ("timeout" in result) throw new Error("timed out");
160
+
161
+ // all — same as all(ctx, …)
162
+ const { user, posts } = await all({
163
+ user: ({ signal }) => fetchUser(id, { signal }),
164
+ posts: ({ signal }) => fetchPosts(id, { signal }),
165
+ });
166
+ });
167
+ ```
168
+
169
+ - Supported: `race`, `all`, `allSettled`, `take`, `delay`. The key is the effect name.
170
+ - `retry`, `timeout` and `poll` are not supported: bound to the task they would look like their handler wrappers.
171
+ - Fields from `createBus({ context })` stay. TypeScript rejects an effect whose name is already a context field of the bus, including one added by an earlier `effectsPlugin`.
172
+ - Free forms keep working: `race(ctx, …)`.
173
+
174
+ ## Tie a promise to the task signal
175
+
176
+ `delay(ctx, ms)` waits and rejects on abort (`ms` can be a function).
177
+
178
+ `cancellable(ctx, executor)` is a promise tied to the task signal. It works like `new Promise`, but the executor gets `{ resolve, reject, onCancel }`.
179
+
180
+ ```js
181
+ import { createBus } from "nanoeffects";
8
182
  import { cancellable } from "nanoeffects/effects";
183
+ import { latest } from "nanoeffects/policies";
9
184
 
185
+ const bus = createBus();
186
+
187
+ // cancellable — promise tied to the task signal; onCancel for cleanups
188
+ // latest — a newer wait aborts the older timer
10
189
  bus.on(
11
190
  "wait",
12
- latest((_, ctx) =>
13
- cancellable(ctx, ({ resolve, onCancel }) => {
191
+ latest((_, ctx) => {
192
+ return cancellable(ctx, ({ resolve, onCancel }) => {
14
193
  const timer = setTimeout(resolve, 5000);
15
194
  onCancel(() => clearTimeout(timer));
16
- }),
17
- ),
195
+ });
196
+ }),
18
197
  );
19
198
  ```
20
199
 
@@ -22,116 +201,93 @@ bus.on(
22
201
  - If the task is already aborted, the executor does not run.
23
202
  - After the promise settles, the abort listener is removed and cleanups are dropped.
24
203
 
25
- `onAbort(ctx, fn)` runs `fn(reason)` on abort (at once if already aborted) and returns a function that unsubscribes:
204
+ ## Run cleanup on abort
205
+
206
+ `onAbort(ctx, fn)` runs `fn(reason)` on abort (at once if already aborted) and returns a function that unsubscribes.
26
207
 
27
- ```ts
208
+ ```js
209
+ import { createBus } from "nanoeffects";
28
210
  import { onAbort } from "nanoeffects/effects";
29
211
 
212
+ const bus = createBus();
213
+
30
214
  bus.on("connect", (url, ctx) => {
31
215
  const socket = new WebSocket(url);
216
+ // onAbort — run cleanup when this task aborts
32
217
  onAbort(ctx, () => socket.close());
33
218
  });
34
219
  ```
35
220
 
36
- ## allSettled
37
-
38
- `allSettled(ctx, effects)` is `all` that waits for every starter and never fails because of one: each result is `{ status: "fulfilled", value }` or `{ status: "rejected", reason }`, in the shape of the input (object or array). Nothing is cancelled when a starter fails. Only an abort of `ctx` rejects it.
221
+ ## Wait for the next event with `take`
39
222
 
40
- ```ts
41
- const { user, posts } = await allSettled(ctx, {
42
- user: ({ signal }) => fetchUser(id, { signal }),
43
- posts: ({ signal }) => fetchPosts(id, { signal }),
44
- });
45
-
46
- if (user.status === "fulfilled") render(user.value);
47
- ```
223
+ A task context has `on` and `off`, like the bus. A subscription made with `ctx.on` ends when the task aborts:
48
224
 
49
- ## Subscribing from a task: ctx.on, ctx.off, take
225
+ ```js
226
+ import { createBus } from "nanoeffects";
50
227
 
51
- A task context has `on` and `off`, like the bus. A subscription made with `ctx.on` ends when the task aborts, so it cannot outlive the work that needed it:
228
+ const bus = createBus();
52
229
 
53
- ```ts
54
230
  bus.on("watch", (_, ctx) => {
55
231
  ctx.on("tick", (n) => console.log(n)); // gone when this task aborts
56
232
  });
57
233
  ```
58
234
 
59
- - The returned function unsubscribes sooner. After the task is aborted, `ctx.on` does nothing.
60
- - A policy passes its handler a child context, so under `latest` the subscriptions of a superseded run end with it.
61
- - `ctx.off` is the bus `off`: `ctx.off(event)` clears every handler of the event, not only the task's.
235
+ - The returned function unsubscribes sooner. After abort, `ctx.on` does nothing.
236
+ - Under `latest`, subscriptions of a superseded run end with that run.
237
+ - `ctx.off(event)` clears every handler of the event on the bus, not only the task’s.
62
238
 
63
239
  `take(ctx, event, options?)` waits for the next `event` and resolves with its payload:
64
240
 
65
- ```ts
241
+ ```js
242
+ import { createBus } from "nanoeffects";
66
243
  import { take, timeout } from "nanoeffects/effects";
67
244
 
245
+ const bus = createBus();
246
+
68
247
  bus.on("login", async (user, ctx) => {
69
248
  const reason = await take(ctx, "logout"); // resolves on the next logout
70
249
  cleanup(user, reason);
71
250
 
72
251
  // wait for a matching event, but not longer than 5 s
73
252
  const id = await timeout(ctx, 5000, ({ signal }) =>
74
- take(ctx, "saved", { signal, predicate: (id) => id === draft.id }),
253
+ take(ctx, "saved", { signal, predicate: (savedId) => savedId === draft.id }),
75
254
  );
76
255
  });
77
256
  ```
78
257
 
79
- - It sees only events emitted after the call, takes one, and unsubscribes.
80
- - `options.predicate` skips payloads it rejects; `options.signal` stops the wait, which is how a `race` or `timeout` starter cancels it.
81
- - On abort of the task or of `options.signal` it unsubscribes and rejects with the abort reason. It is traced as a `take` group.
82
-
83
- ## retry, timeout, poll
258
+ - Sees only events emitted after the call, takes one, and unsubscribes.
259
+ - `options.predicate` skips payloads it rejects. In TypeScript, a type guard (`(payload): payload is Admin`) narrows the type of the result.
260
+ - `options.signal` stops the wait (how a `race` or `timeout` starter cancels it).
261
+ - On abort of the task or of `options.signal`, unsubscribes and rejects with the abort reason.
84
262
 
85
- These three have two forms. As a **Task wrapper** they take a function `(payload, ctx) => result` and return one, so they go straight into `on`, next to the policies, with no `ctx` in their arguments. As a **call inside a body** they take `ctx` first.
263
+ ## Run several starters together
86
264
 
87
- ```ts
88
- import { poll, retry, timeout } from "nanoeffects/effects";
265
+ `all(ctx, effects)` runs starters together. One failure cancels the rest. Shape of the input (object or array) is preserved in the result.
89
266
 
90
- const loadUser = (id: string, { signal }) => fetchUser(id, { signal });
267
+ `allSettled(ctx, effects)` waits for every starter and never fails because of one. Each result is `{ status: "fulfilled", value }` or `{ status: "rejected", reason }`. Nothing is cancelled when a starter fails. Only an abort of `ctx` rejects it.
91
268
 
92
- // wrappers: up to 4 attempts with a backoff, each within 5 s; a newer call supersedes the older
93
- bus.on(
94
- "load",
95
- latest(
96
- retry(timeout(5000, loadUser), {
97
- times: 4,
98
- delay: (attempt) => 2 ** attempt * 100,
99
- shouldRetry: (error) => !isFatal(error),
100
- }),
101
- ),
102
- );
269
+ ```js
270
+ import { createBus } from "nanoeffects";
271
+ import { allSettled } from "nanoeffects/effects";
103
272
 
104
- // calls refresh every 10 s until the task is aborted
105
- bus.on("watch", poll(10_000, (_, { signal }) => refresh({ signal })));
273
+ const bus = createBus();
106
274
 
107
- // a wrapped Task runs as a child of a handler with ctx.run: the only place where ctx is written
108
275
  bus.on("open", async (id, ctx) => {
109
- const user = await ctx.run(retry(timeout(5000, loadUser)), id);
110
- });
111
-
112
- // one call inside a body: ctx first
113
- bus.on("load", async (id, ctx) => {
114
- const user = await retry(ctx, () => fetchUser(id, { signal: ctx.signal }), { times: 4 });
115
- const data = await timeout(ctx, 5000, ({ signal }) => fetchData(id, { signal }));
116
- await poll(ctx, () => refresh(), 10_000);
276
+ const { user, posts } = await allSettled(ctx, {
277
+ user: ({ signal }) => fetchUser(id, { signal }),
278
+ posts: ({ signal }) => fetchPosts(id, { signal }),
279
+ });
280
+
281
+ if (user.status === "fulfilled") {
282
+ render(user.value);
283
+ }
117
284
  });
118
285
  ```
119
286
 
120
- Use the wrapper form for a whole handler or a whole `ctx.run`, and the `ctx` form for one call in the middle of a body. The forms are told apart by the first argument: a function (`retry`), a number (`timeout`), a number or a function (`poll`) is a wrapper; an object is `ctx`. Each attempt or call of a wrapper gets a context of its own, a child of the caller's, so an abort of the caller reaches it and an abort of one attempt (a timeout) does not reach the caller. The attempt number is passed only in the `ctx` form.
121
-
122
- - `retry`: `times` is the total number of attempts (default 3), `delay` is a number or `(attempt, error) => ms`, `shouldRetry(error, attempt)` can stop early. `fn` gets the attempt number, starting at 1. On abort it rejects with the abort reason.
123
- - `timeout`: the error is a `DOMException` named `TimeoutError`, like `AbortSignal.timeout`. It rejects even if the starter ignores its signal.
124
- - `poll`: like patronum `interval`. The first call comes after `ms` (`leading: true` also calls at once), and the next ticks keep a steady rate whatever the duration of `fn`, so a slow `fn` can overlap with the next call; `waitForFn: true` starts each pause when the call ends instead, so calls never overlap. `fn` gets the call number from 0. An error from `fn` stops polling and rejects; an abort ends it normally and clears the timer.
125
- - A pause can be a function wherever a time is given, for backoff or jitter: `retry`'s `delay` gets `(attempt, error)`, `poll`'s `ms` gets the number of the call the pause leads to, and `delay` itself takes a function called once when it starts:
126
-
127
- ```ts
128
- retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, ...
129
- poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows down to once a minute
130
- await delay(ctx, () => 200 + Math.random() * 200); // 200 to 400 ms
131
- ```
287
+ `race(ctx, effects)` settles with the first starter that finishes and cancels the others.
132
288
 
133
- - All three are traced: the effect is a group (`retry`, `timeout`, `poll`) and each attempt or call is a `run` child.
289
+ ## Pass `ctx` outside a task
134
290
 
135
- The other effects wait inside a body, so `ctx` is their first argument: `delay(ctx, ms)`, `all(ctx, effects)`, `allSettled(ctx, effects)`, `race(ctx, effects)`, `cancellable(ctx, executor)`, `onAbort(ctx, fn)`, `take(ctx, event, options)`, and `retry`, `timeout` and `poll` in their `ctx` form. It is required; outside a task pass `{}` or any object with a `signal`.
291
+ Effects that wait inside a body take `ctx` first: `delay`, `all`, `allSettled`, `race`, `cancellable`, `onAbort`, `take`, and the `ctx` forms of `retry` / `timeout` / `poll`. Outside a task, pass `{}` or any object with a `signal`.
136
292
 
137
293
  For `fetch` and other APIs that take a signal, pass `ctx.signal`. A timeout without `race`: `AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)])`.
package/docs/errors.md CHANGED
@@ -1,21 +1,34 @@
1
- # Errors and unheard events
1
+ # Handle errors and unheard events
2
2
 
3
- `emit` is a plain loop over the handlers, like in nanoevents: if a handler throws, the remaining handlers are skipped and the error reaches the caller of `emit`. A rejected promise of an async handler is left unhandled.
3
+ Defaults match a small bus. Set these when you want isolation or typo warnings.
4
4
 
5
- Pass `onError` to isolate handlers instead. Then every handler runs, and thrown errors and rejections of async handlers (including ones wrapped in `latest`, `debounce` and the like) go to `onError`; an `onError` that throws is logged to `console.error`.
5
+ ## Isolate failing handlers
6
6
 
7
- ```ts
8
- const bus = createBus<Events>({
9
- onError: (error, { type, payload }) => report(error, type, payload),
7
+ `emit` is a plain loop over the handlers (like nanoevents). If a handler throws, the remaining handlers are skipped and the error reaches the caller of `emit`. A rejected promise of an async handler is left unhandled.
8
+
9
+ Pass `onError` to isolate handlers instead. Then every handler runs. Thrown errors and rejections of async handlers (including those wrapped in `latest`, `debounce`, and the like) go to `onError`. An `onError` that throws is logged to `console.error`.
10
+
11
+ ```js
12
+ import { createBus } from "nanoeffects";
13
+
14
+ // createBus — onError isolates failing handlers so others still run
15
+ const bus = createBus({
16
+ onError: (error, { type, payload }) => {
17
+ report(error, type, payload);
18
+ },
10
19
  });
11
20
  ```
12
21
 
13
- ## Events that nobody listens to
22
+ ## Warn when nobody listens
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.
25
+
26
+ Use it as a warning in development:
14
27
 
15
- A typo in an event name, `emit("serch")`, does nothing and says nothing. `onUnhandled` is called when an event is emitted and nobody listens to it (a listener of every event, `"*"`, counts). It is not called for commands, which always have a handler to run. Use it as a warning in development:
28
+ ```js
29
+ import { createBus } from "nanoeffects";
16
30
 
17
- ```ts
18
- const bus = createBus<Events>({
31
+ const bus = createBus({
19
32
  onUnhandled: import.meta.env.DEV
20
33
  ? ({ type }) => console.warn(`Nobody listens to "${type}"`)
21
34
  : void 0,