nanoeffects 0.3.0 → 0.4.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 +50 -55
- package/dist/{action-Bdfp-Wvd.d.ts → action-creator-D48fp13T.d.ts} +64 -72
- package/dist/actions.d.ts +40 -6
- package/dist/actions.js +1 -1
- package/dist/bus-Cho463Sq.js +1 -0
- package/dist/dev.d.ts +2 -2
- package/dist/effects.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/{module-I1ixyJzx.d.ts → module-DVX8aYlq.d.ts} +9 -8
- package/dist/notrace/{action-Bdfp-Wvd.d.ts → action-creator-D48fp13T.d.ts} +64 -72
- package/dist/notrace/actions.d.ts +47 -12
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/bus-B12SvxLv.js +1 -0
- package/dist/notrace/effects.d.ts +1 -1
- package/dist/notrace/index.d.ts +2 -2
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/notrace/redux.js +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/policies.d.ts +1 -1
- package/dist/redux.d.ts +1 -1
- package/dist/redux.js +1 -1
- package/dist/trace.d.ts +1 -1
- package/docs/README.md +24 -9
- package/docs/actions.md +117 -41
- package/docs/context.md +27 -8
- package/docs/effects.md +152 -73
- package/docs/errors.md +23 -10
- package/docs/guide.md +236 -129
- package/docs/hmr.md +25 -13
- package/docs/observable.md +10 -9
- package/docs/package.md +14 -0
- package/docs/policies.md +92 -30
- package/docs/semantics.md +5 -3
- package/docs/stores.md +96 -22
- package/docs/tracing.md +69 -54
- package/package.json +5 -4
- package/dist/bus-CRnlwmQ4.js +0 -1
- package/dist/notrace/bus-Dx0l9X6h.js +0 -1
- package/dist/notrace/event-0RIBGYqS.js +0 -1
package/docs/actions.md
CHANGED
|
@@ -1,68 +1,141 @@
|
|
|
1
1
|
# Actions, commands, 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
|
|
|
5
|
-
`
|
|
5
|
+
If you only need `on` / `emit` or policies, stay in the [Guide](guide.md) until you need a result from `emit` or a mountable domain.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Recipe without a UI framework: [`examples/modules.ts`](../examples/modules.ts).
|
|
8
|
+
|
|
9
|
+
## Return a value from `emit` with a command
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { createBus } from "nanoeffects";
|
|
13
|
+
import { createCommand } from "nanoeffects/actions";
|
|
14
|
+
|
|
15
|
+
const bus = createBus();
|
|
16
|
+
|
|
17
|
+
// createCommand — emit runs the handler and returns its result
|
|
18
|
+
const double = createCommand("math/double", (value) => value * 2);
|
|
19
|
+
bus.emit(double(21)); // 42
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Describe events, commands, and jobs
|
|
23
|
+
|
|
24
|
+
`nanoeffects/actions` has the pieces to name what a bus does, apart from the code that does it.
|
|
25
|
+
|
|
26
|
+
```js
|
|
8
27
|
import { createAction, createCommand, createJob } from "nanoeffects/actions";
|
|
9
28
|
|
|
10
|
-
//
|
|
11
|
-
// the payload types come from the event map of the bus: type Events = { "user/loaded": User; "user/failed": Error }
|
|
29
|
+
// createAction — event with a payload (like RTK createAction)
|
|
12
30
|
const userLoaded = createAction("user/loaded");
|
|
13
31
|
const loadFailed = createAction("user/failed");
|
|
14
32
|
|
|
15
|
-
//
|
|
16
|
-
const getUser = createCommand("user/get", (id
|
|
33
|
+
// createCommand with a handler — emit returns what the handler returns
|
|
34
|
+
const getUser = createCommand("user/get", (id, { signal }) => {
|
|
35
|
+
return fetchUser(id, { signal });
|
|
36
|
+
});
|
|
17
37
|
|
|
18
|
-
//
|
|
19
|
-
const sendMail = createCommand
|
|
38
|
+
// createCommand without a handler — a contract; register the work elsewhere
|
|
39
|
+
const sendMail = createCommand("mail/send");
|
|
20
40
|
|
|
21
|
-
//
|
|
41
|
+
// createJob — command that also emits success / fail actions
|
|
22
42
|
const loadUser = createJob("user/load", fetchUser, {
|
|
23
|
-
success: userLoaded,
|
|
24
|
-
fail: "user/failed",
|
|
43
|
+
success: userLoaded,
|
|
44
|
+
fail: "user/failed",
|
|
25
45
|
});
|
|
26
46
|
```
|
|
27
47
|
|
|
28
|
-
|
|
48
|
+
### Actions
|
|
49
|
+
|
|
50
|
+
`createAction(type)` returns a creator. Without generics it keeps the literal type; the bus checks the payload against its event map. Without an event map, give the payload type: `createAction<User>("user/loaded")`.
|
|
51
|
+
|
|
52
|
+
- `userLoaded(user)` → `{ type, payload }`
|
|
53
|
+
- `userLoaded.type` — the type string
|
|
54
|
+
- `userLoaded.match(action)` — narrows an action
|
|
55
|
+
- `createAction(type, prepare)` — build the payload from several arguments
|
|
56
|
+
- Works as the event of its type: `bus.emit(userLoaded(user))`, `bus.on(userLoaded, fn)`
|
|
29
57
|
|
|
30
|
-
|
|
58
|
+
### Commands
|
|
31
59
|
|
|
32
|
-
|
|
60
|
+
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.
|
|
33
61
|
|
|
34
|
-
|
|
35
|
-
|
|
62
|
+
- Wrap the handler in a policy: `createCommand("search", latest(fn))`.
|
|
63
|
+
- `command.match(action)` narrows like an action creator’s `match`.
|
|
36
64
|
|
|
37
|
-
|
|
38
|
-
const commands = bus.use(commandsPlugin([bindHandler(sendMail, smtpSend)])); // attached to the bus
|
|
65
|
+
**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.
|
|
39
66
|
|
|
40
|
-
|
|
41
|
-
|
|
67
|
+
```js
|
|
68
|
+
import { createBus } from "nanoeffects";
|
|
69
|
+
import {
|
|
70
|
+
bindHandler,
|
|
71
|
+
commandsPlugin,
|
|
72
|
+
createCommand,
|
|
73
|
+
} from "nanoeffects/actions";
|
|
74
|
+
|
|
75
|
+
const sendMail = createCommand("mail/send");
|
|
76
|
+
const getUser = createCommand("user/get");
|
|
77
|
+
|
|
78
|
+
const bus = createBus();
|
|
79
|
+
|
|
80
|
+
// commandsPlugin — registry of handlers for this bus
|
|
81
|
+
const commands = bus.use(
|
|
82
|
+
commandsPlugin([bindHandler(sendMail, smtpSend)]),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// later, e.g. in a test — latest binding wins
|
|
86
|
+
const unbind = commands.handle(getUser, async () => fakeUser);
|
|
87
|
+
unbind();
|
|
42
88
|
```
|
|
43
89
|
|
|
44
|
-
A command with no handler anywhere throws `No handler for command "..."` from `emit
|
|
90
|
+
A command with no handler anywhere throws `No handler for command "..."` from `emit` (the call itself only builds the action).
|
|
91
|
+
|
|
92
|
+
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 plugin on the same bus replaces it. There is no `commands` option in `createBus`; assign a custom runner with `bus.commands = { run }`.
|
|
93
|
+
|
|
94
|
+
### Jobs
|
|
95
|
+
|
|
96
|
+
`createJob(type, handler, { success, fail })` makes a command that also sends its outcome as actions, when nobody waits for the result. It returns the command with `loadUser.success` and `loadUser.fail` on it.
|
|
97
|
+
|
|
98
|
+
- On success, emits `loadUser.success(result)`.
|
|
99
|
+
- On failure, emits `loadUser.fail(error)`.
|
|
100
|
+
- `success` / `fail` are a string (job makes an action creator) or an existing action creator. Missing ones become `<type>/success` or `<type>/fail`.
|
|
101
|
+
- `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.
|
|
102
|
+
- Outcome actions are emitted as the task that ran the job (also for a handler bound with `commands.handle`). Nothing is sent after abort.
|
|
103
|
+
- A missing handler is not an outcome: `emit` throws `No handler for command "..."`.
|
|
104
|
+
- Calling the job only builds `{ type, payload }`; the handler runs on `emit`.
|
|
105
|
+
|
|
106
|
+
## Mount domains with modules
|
|
107
|
+
|
|
108
|
+
**Problem.** A domain needs several subscriptions and command bindings. With plain `bus.on` / `commands.handle` you must remember every unsubscribe.
|
|
45
109
|
|
|
46
|
-
**
|
|
110
|
+
**When.** You want one mount / one unmount for that domain. Skip modules while a few top-level `bus.on` calls are enough.
|
|
47
111
|
|
|
48
|
-
|
|
49
|
-
- `bus.emit(command, payload)` returns a promise of the result. An error of the handler always counts as handled: it goes to `loadUser.fail` and the promise resolves with `undefined`. For a result that must reject, use a command.
|
|
50
|
-
- The actions are emitted as the task that ran the job, also for a handler bound with `commands.handle`, and are not sent once the task is aborted. A missing handler is not an outcome: `emit` throws `No handler for command "..."`.
|
|
51
|
-
- Calling `command(payload)` directly runs only the handler.
|
|
112
|
+
`bus.use(modulePlugin(modules))` assembles an app from modules and returns `mount`, `unmount`, `isMounted`, `dispose`, and `commands`.
|
|
52
113
|
|
|
53
|
-
|
|
114
|
+
```js
|
|
115
|
+
import { createBus } from "nanoeffects";
|
|
116
|
+
import {
|
|
117
|
+
createCommand,
|
|
118
|
+
createModule,
|
|
119
|
+
modulePlugin,
|
|
120
|
+
} from "nanoeffects/actions";
|
|
54
121
|
|
|
55
|
-
|
|
122
|
+
const getUser = createCommand("user/get");
|
|
123
|
+
const saveUser = createCommand("user/save", persistUser);
|
|
56
124
|
|
|
57
|
-
|
|
58
|
-
const userModule = createModule
|
|
59
|
-
m.handle(getUser, fetchUser);
|
|
60
|
-
m.use(saveUser, authModule);
|
|
61
|
-
m.on("login", (user, ctx) =>
|
|
62
|
-
|
|
125
|
+
// createModule — one domain; unmount undoes everything below
|
|
126
|
+
const userModule = createModule("user", (m) => {
|
|
127
|
+
m.handle(getUser, fetchUser);
|
|
128
|
+
m.use(saveUser, authModule);
|
|
129
|
+
m.on("login", (user, ctx) => {
|
|
130
|
+
ctx.emit("loaded", user);
|
|
131
|
+
});
|
|
132
|
+
m.spawn(async (_, ctx) => {
|
|
133
|
+
/* background work; ends when the module unmounts */
|
|
134
|
+
});
|
|
63
135
|
});
|
|
64
136
|
|
|
65
|
-
const bus = createBus
|
|
137
|
+
const bus = createBus();
|
|
138
|
+
// modulePlugin — mount modules on the bus
|
|
66
139
|
const app = bus.use(modulePlugin([userModule]));
|
|
67
140
|
|
|
68
141
|
const unmount = app.mount(orderModule); // later, e.g. after a lazy import
|
|
@@ -70,9 +143,12 @@ app.commands.handle(getUser, fakeUser); // in a test
|
|
|
70
143
|
app.dispose(); // unmounts every module
|
|
71
144
|
```
|
|
72
145
|
|
|
73
|
-
- **Unmounting undoes everything** the module did:
|
|
74
|
-
- **A module is known by its name.** Mounting a module whose name is taken replaces the mounted one
|
|
75
|
-
- **
|
|
146
|
+
- **Unmounting undoes everything** the module did: subscriptions, bound commands, sub-modules, and background tasks (aborted).
|
|
147
|
+
- **A module is known by its name.** Mounting a module whose name is taken replaces the mounted one (see [Hot reload](hmr.md)).
|
|
148
|
+
- **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`.
|
|
76
149
|
- A disposed app cannot mount modules.
|
|
77
|
-
-
|
|
78
|
-
-
|
|
150
|
+
- Module context = task context plus `handle`, `use`, and `spawn`:
|
|
151
|
+
- `handle` — give a command a handler (contract, or override).
|
|
152
|
+
- `use` — bring sub-modules and commands that carry a handler; the returned function undoes that call.
|
|
153
|
+
- `spawn` — background task, aborted on unmount.
|
|
154
|
+
- 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.
|
package/docs/context.md
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Put your own fields on every task
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Optional: put a store, client, or logger on every task. Skip until many handlers need the same dependency.
|
|
4
|
+
|
|
5
|
+
## Pass values at runtime
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { createBus } from "nanoeffects";
|
|
9
|
+
|
|
10
|
+
// createBus({ context }) — every task gets these fields
|
|
11
|
+
const bus = createBus({ context: { store } });
|
|
12
|
+
|
|
13
|
+
bus.on("save", (draft, { store }) => {
|
|
14
|
+
store.put(draft);
|
|
15
|
+
});
|
|
16
|
+
```
|
|
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.
|
|
19
|
+
|
|
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
|
+
|
|
22
|
+
## Type the fields (optional)
|
|
23
|
+
|
|
24
|
+
`WithContext` on the event map only tells TypeScript that handlers of **this** bus have those fields. It does not wire anything at runtime and is not app-wide — another bus can have different fields or none.
|
|
4
25
|
|
|
5
26
|
```ts
|
|
27
|
+
import { createBus, type WithContext } from "nanoeffects";
|
|
28
|
+
|
|
29
|
+
// Events — event name → payload
|
|
30
|
+
// WithContext — checker-only: handlers also have ctx.store
|
|
6
31
|
type Events = { save: Draft } & WithContext<{ store: Store }>;
|
|
7
32
|
|
|
8
33
|
const bus = createBus<Events>({ context: { store } });
|
|
9
|
-
bus.on("save", (draft, { store }) => store.put(draft));
|
|
10
34
|
```
|
|
11
|
-
|
|
12
|
-
The fields are in the context of handlers, `ctx.run`, listeners added with `ctx.on`, commands, modules and the child tasks of effects and policies, and the compiler checks them: a handler of a bus without `WithContext` has no `store`, and `context` must have the fields that the map names. Two buses can have different fields.
|
|
13
|
-
|
|
14
|
-
- The fields of the library (`signal`, `emit`, `run`, ...) win when the names are the same.
|
|
15
|
-
- `context` can also be a function, `(task) => fields`, called with `{ type, payload }` of each task, for fields that depend on the task.
|
package/docs/effects.md
CHANGED
|
@@ -1,20 +1,122 @@
|
|
|
1
1
|
# Effects and cancellation
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (backoff or jitter):
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, …
|
|
91
|
+
poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows to once a minute
|
|
92
|
+
await delay(ctx, () => 200 + Math.random() * 200); // 200–400 ms
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
All three are traced: the effect is a group (`retry`, `timeout`, `poll`) and each attempt or call is a `run` child.
|
|
96
|
+
|
|
97
|
+
## Tie a promise to the task signal
|
|
98
|
+
|
|
99
|
+
`delay(ctx, ms)` waits and rejects on abort (`ms` can be a function).
|
|
100
|
+
|
|
101
|
+
`cancellable(ctx, executor)` is a promise tied to the task signal. It works like `new Promise`, but the executor gets `{ resolve, reject, onCancel }`.
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
import { createBus } from "nanoeffects";
|
|
8
105
|
import { cancellable } from "nanoeffects/effects";
|
|
106
|
+
import { latest } from "nanoeffects/policies";
|
|
9
107
|
|
|
108
|
+
const bus = createBus();
|
|
109
|
+
|
|
110
|
+
// cancellable — promise tied to the task signal; onCancel for cleanups
|
|
111
|
+
// latest — a newer wait aborts the older timer
|
|
10
112
|
bus.on(
|
|
11
113
|
"wait",
|
|
12
|
-
latest((_, ctx) =>
|
|
13
|
-
cancellable(ctx, ({ resolve, onCancel }) => {
|
|
114
|
+
latest((_, ctx) => {
|
|
115
|
+
return cancellable(ctx, ({ resolve, onCancel }) => {
|
|
14
116
|
const timer = setTimeout(resolve, 5000);
|
|
15
117
|
onCancel(() => clearTimeout(timer));
|
|
16
|
-
})
|
|
17
|
-
),
|
|
118
|
+
});
|
|
119
|
+
}),
|
|
18
120
|
);
|
|
19
121
|
```
|
|
20
122
|
|
|
@@ -22,116 +124,93 @@ bus.on(
|
|
|
22
124
|
- If the task is already aborted, the executor does not run.
|
|
23
125
|
- After the promise settles, the abort listener is removed and cleanups are dropped.
|
|
24
126
|
|
|
25
|
-
|
|
127
|
+
## Run cleanup on abort
|
|
128
|
+
|
|
129
|
+
`onAbort(ctx, fn)` runs `fn(reason)` on abort (at once if already aborted) and returns a function that unsubscribes.
|
|
26
130
|
|
|
27
|
-
```
|
|
131
|
+
```js
|
|
132
|
+
import { createBus } from "nanoeffects";
|
|
28
133
|
import { onAbort } from "nanoeffects/effects";
|
|
29
134
|
|
|
135
|
+
const bus = createBus();
|
|
136
|
+
|
|
30
137
|
bus.on("connect", (url, ctx) => {
|
|
31
138
|
const socket = new WebSocket(url);
|
|
139
|
+
// onAbort — run cleanup when this task aborts
|
|
32
140
|
onAbort(ctx, () => socket.close());
|
|
33
141
|
});
|
|
34
142
|
```
|
|
35
143
|
|
|
36
|
-
##
|
|
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.
|
|
144
|
+
## Wait for the next event with `take`
|
|
39
145
|
|
|
40
|
-
|
|
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
|
-
```
|
|
146
|
+
A task context has `on` and `off`, like the bus. A subscription made with `ctx.on` ends when the task aborts:
|
|
48
147
|
|
|
49
|
-
|
|
148
|
+
```js
|
|
149
|
+
import { createBus } from "nanoeffects";
|
|
50
150
|
|
|
51
|
-
|
|
151
|
+
const bus = createBus();
|
|
52
152
|
|
|
53
|
-
```ts
|
|
54
153
|
bus.on("watch", (_, ctx) => {
|
|
55
154
|
ctx.on("tick", (n) => console.log(n)); // gone when this task aborts
|
|
56
155
|
});
|
|
57
156
|
```
|
|
58
157
|
|
|
59
|
-
- The returned function unsubscribes sooner. After
|
|
60
|
-
-
|
|
61
|
-
- `ctx.off
|
|
158
|
+
- The returned function unsubscribes sooner. After abort, `ctx.on` does nothing.
|
|
159
|
+
- Under `latest`, subscriptions of a superseded run end with that run.
|
|
160
|
+
- `ctx.off(event)` clears every handler of the event on the bus, not only the task’s.
|
|
62
161
|
|
|
63
162
|
`take(ctx, event, options?)` waits for the next `event` and resolves with its payload:
|
|
64
163
|
|
|
65
|
-
```
|
|
164
|
+
```js
|
|
165
|
+
import { createBus } from "nanoeffects";
|
|
66
166
|
import { take, timeout } from "nanoeffects/effects";
|
|
67
167
|
|
|
168
|
+
const bus = createBus();
|
|
169
|
+
|
|
68
170
|
bus.on("login", async (user, ctx) => {
|
|
69
171
|
const reason = await take(ctx, "logout"); // resolves on the next logout
|
|
70
172
|
cleanup(user, reason);
|
|
71
173
|
|
|
72
174
|
// wait for a matching event, but not longer than 5 s
|
|
73
175
|
const id = await timeout(ctx, 5000, ({ signal }) =>
|
|
74
|
-
take(ctx, "saved", { signal, predicate: (
|
|
176
|
+
take(ctx, "saved", { signal, predicate: (savedId) => savedId === draft.id }),
|
|
75
177
|
);
|
|
76
178
|
});
|
|
77
179
|
```
|
|
78
180
|
|
|
79
|
-
-
|
|
80
|
-
- `options.predicate` skips payloads it rejects
|
|
81
|
-
-
|
|
181
|
+
- Sees only events emitted after the call, takes one, and unsubscribes.
|
|
182
|
+
- `options.predicate` skips payloads it rejects.
|
|
183
|
+
- `options.signal` stops the wait (how a `race` or `timeout` starter cancels it).
|
|
184
|
+
- On abort of the task or of `options.signal`, unsubscribes and rejects with the abort reason.
|
|
82
185
|
|
|
83
|
-
##
|
|
186
|
+
## Run several starters together
|
|
84
187
|
|
|
85
|
-
|
|
188
|
+
`all(ctx, effects)` runs starters together. One failure cancels the rest. Shape of the input (object or array) is preserved in the result.
|
|
86
189
|
|
|
87
|
-
|
|
88
|
-
import { poll, retry, timeout } from "nanoeffects/effects";
|
|
190
|
+
`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.
|
|
89
191
|
|
|
90
|
-
|
|
192
|
+
```js
|
|
193
|
+
import { createBus } from "nanoeffects";
|
|
194
|
+
import { allSettled } from "nanoeffects/effects";
|
|
91
195
|
|
|
92
|
-
|
|
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
|
-
);
|
|
196
|
+
const bus = createBus();
|
|
103
197
|
|
|
104
|
-
// calls refresh every 10 s until the task is aborted
|
|
105
|
-
bus.on("watch", poll(10_000, (_, { signal }) => refresh({ signal })));
|
|
106
|
-
|
|
107
|
-
// a wrapped Task runs as a child of a handler with ctx.run: the only place where ctx is written
|
|
108
198
|
bus.on("open", async (id, ctx) => {
|
|
109
|
-
const user = await ctx
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
199
|
+
const { user, posts } = await allSettled(ctx, {
|
|
200
|
+
user: ({ signal }) => fetchUser(id, { signal }),
|
|
201
|
+
posts: ({ signal }) => fetchPosts(id, { signal }),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (user.status === "fulfilled") {
|
|
205
|
+
render(user.value);
|
|
206
|
+
}
|
|
117
207
|
});
|
|
118
208
|
```
|
|
119
209
|
|
|
120
|
-
|
|
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
|
-
```
|
|
210
|
+
`race(ctx, effects)` settles with the first starter that finishes and cancels the others.
|
|
132
211
|
|
|
133
|
-
|
|
212
|
+
## Pass `ctx` outside a task
|
|
134
213
|
|
|
135
|
-
|
|
214
|
+
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
215
|
|
|
137
216
|
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
|
-
#
|
|
1
|
+
# Handle errors and unheard events
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Defaults match a small bus. Set these when you want isolation or typo warnings.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Isolate failing handlers
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
28
|
+
```js
|
|
29
|
+
import { createBus } from "nanoeffects";
|
|
16
30
|
|
|
17
|
-
|
|
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,
|