nanoeffects 0.1.0 → 0.2.1
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 +62 -61
- package/dist/{action-BM-UdmJr.d.ts → action-rqZEnRIp.d.ts} +12 -11
- package/dist/actions.d.ts +47 -185
- package/dist/actions.js +1 -1
- package/dist/bus-CRnlwmQ4.js +1 -0
- package/dist/context-DlA80GOA.js +1 -0
- package/dist/dev.d.ts +46 -0
- package/dist/dev.js +1 -0
- package/dist/effects.d.ts +61 -21
- package/dist/effects.js +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -1
- package/dist/module-BSCdR9D1.d.ts +167 -0
- package/dist/notrace/{action-BM-UdmJr.d.ts → action-rqZEnRIp.d.ts} +12 -11
- package/dist/notrace/actions.d.ts +71 -45
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/bus-Dx0l9X6h.js +1 -0
- package/dist/notrace/context-yAWj0BFZ.js +1 -0
- package/dist/notrace/{delay-Cko_Q9hl.js → delay-Cgvn16oY.js} +1 -1
- package/dist/notrace/effects.d.ts +61 -21
- package/dist/notrace/effects.js +1 -1
- package/dist/notrace/index.d.ts +1 -2
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/policies.js +1 -1
- package/dist/notrace/redux.d.ts +2 -28
- package/dist/notrace/redux.js +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/policies.d.ts +1 -1
- package/dist/policies.js +1 -1
- package/dist/production/dev.js +1 -0
- package/dist/redux.d.ts +2 -28
- package/dist/redux.js +1 -1
- package/dist/trace.d.ts +22 -4
- package/dist/trace.js +1 -1
- package/docs/README.md +4 -1
- package/docs/actions.md +21 -11
- package/docs/context.md +0 -1
- package/docs/effects.md +49 -31
- package/docs/errors.md +4 -4
- package/docs/guide.md +229 -0
- package/docs/hmr.md +20 -58
- package/docs/policies.md +4 -4
- package/docs/stores.md +0 -2
- package/docs/tracing.md +21 -8
- package/package.json +25 -5
- package/dist/bus-RUiw8f9p.js +0 -1
- package/dist/context-BXQ6Einm.js +0 -1
- package/dist/index-CaxrMD1A.d.ts +0 -1
- package/dist/notrace/bus-CFxgpsRL.js +0 -1
- package/dist/notrace/context-DGPIFXxI.js +0 -1
- package/dist/notrace/index-CaxrMD1A.d.ts +0 -1
package/docs/effects.md
CHANGED
|
@@ -8,13 +8,13 @@ An abort only raises a flag: your code decides to stop. `nanoeffects/effects` ha
|
|
|
8
8
|
import { cancellable } from "nanoeffects/effects";
|
|
9
9
|
|
|
10
10
|
bus.on(
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
"wait",
|
|
12
|
+
latest((_, ctx) =>
|
|
13
|
+
cancellable(ctx, ({ resolve, onCancel }) => {
|
|
14
|
+
const timer = setTimeout(resolve, 5000);
|
|
15
|
+
onCancel(() => clearTimeout(timer));
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
18
|
);
|
|
19
19
|
```
|
|
20
20
|
|
|
@@ -28,8 +28,8 @@ bus.on(
|
|
|
28
28
|
import { onAbort } from "nanoeffects/effects";
|
|
29
29
|
|
|
30
30
|
bus.on("connect", (url, ctx) => {
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
const socket = new WebSocket(url);
|
|
32
|
+
onAbort(ctx, () => socket.close());
|
|
33
33
|
});
|
|
34
34
|
```
|
|
35
35
|
|
|
@@ -39,8 +39,8 @@ bus.on("connect", (url, ctx) => {
|
|
|
39
39
|
|
|
40
40
|
```ts
|
|
41
41
|
const { user, posts } = await allSettled(ctx, {
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
user: ({ signal }) => fetchUser(id, { signal }),
|
|
43
|
+
posts: ({ signal }) => fetchPosts(id, { signal }),
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
if (user.status === "fulfilled") render(user.value);
|
|
@@ -52,7 +52,7 @@ A task context has `on` and `off`, like the bus. A subscription made with `ctx.o
|
|
|
52
52
|
|
|
53
53
|
```ts
|
|
54
54
|
bus.on("watch", (_, ctx) => {
|
|
55
|
-
|
|
55
|
+
ctx.on("tick", (n) => console.log(n)); // gone when this task aborts
|
|
56
56
|
});
|
|
57
57
|
```
|
|
58
58
|
|
|
@@ -66,13 +66,13 @@ bus.on("watch", (_, ctx) => {
|
|
|
66
66
|
import { take, timeout } from "nanoeffects/effects";
|
|
67
67
|
|
|
68
68
|
bus.on("login", async (user, ctx) => {
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
const reason = await take(ctx, "logout"); // resolves on the next logout
|
|
70
|
+
cleanup(user, reason);
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
// wait for a matching event, but not longer than 5 s
|
|
73
|
+
const id = await timeout(ctx, 5000, ({ signal }) =>
|
|
74
|
+
take(ctx, "saved", { signal, predicate: (id) => id === draft.id }),
|
|
75
|
+
);
|
|
76
76
|
});
|
|
77
77
|
```
|
|
78
78
|
|
|
@@ -82,25 +82,43 @@ bus.on("login", async (user, ctx) => {
|
|
|
82
82
|
|
|
83
83
|
## retry, timeout, poll
|
|
84
84
|
|
|
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.
|
|
86
|
+
|
|
85
87
|
```ts
|
|
86
88
|
import { poll, retry, timeout } from "nanoeffects/effects";
|
|
87
89
|
|
|
90
|
+
const loadUser = (id: string, { signal }) => fetchUser(id, { signal });
|
|
91
|
+
|
|
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
|
+
);
|
|
103
|
+
|
|
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
|
+
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
|
|
88
113
|
bus.on("load", async (id, ctx) => {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
delay: (attempt) => 2 ** attempt * 100,
|
|
93
|
-
shouldRetry: (error) => !isFatal(error),
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
// aborts the starter's signal and rejects with a TimeoutError after 5 s
|
|
97
|
-
const data = await timeout(ctx, 5000, ({ signal }) => fetchData(id, { signal }));
|
|
98
|
-
|
|
99
|
-
// calls refresh every 10 s; resolves when the task aborts
|
|
100
|
-
await poll(ctx, () => refresh(), 10_000);
|
|
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);
|
|
101
117
|
});
|
|
102
118
|
```
|
|
103
119
|
|
|
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
|
+
|
|
104
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.
|
|
105
123
|
- `timeout`: the error is a `DOMException` named `TimeoutError`, like `AbortSignal.timeout`. It rejects even if the starter ignores its signal.
|
|
106
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.
|
|
@@ -114,6 +132,6 @@ await delay(ctx, () => 200 + Math.random() * 200); // 200 to 400 ms
|
|
|
114
132
|
|
|
115
133
|
- All three are traced: the effect is a group (`retry`, `timeout`, `poll`) and each attempt or call is a `run` child.
|
|
116
134
|
|
|
117
|
-
|
|
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`.
|
|
118
136
|
|
|
119
137
|
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
|
@@ -6,7 +6,7 @@ Pass `onError` to isolate handlers instead. Then every handler runs, and thrown
|
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
const bus = createBus<Events>({
|
|
9
|
-
|
|
9
|
+
onError: (error, { type, payload }) => report(error, type, payload),
|
|
10
10
|
});
|
|
11
11
|
```
|
|
12
12
|
|
|
@@ -16,8 +16,8 @@ A typo in an event name, `emit("serch")`, does nothing and says nothing. `onUnha
|
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
18
|
const bus = createBus<Events>({
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
onUnhandled: import.meta.env.DEV
|
|
20
|
+
? ({ type }) => console.warn(`Nobody listens to "${type}"`)
|
|
21
|
+
: void 0,
|
|
22
22
|
});
|
|
23
23
|
```
|
package/docs/guide.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# Guide
|
|
2
|
+
|
|
3
|
+
From a bus with two calls to an app with modules, one step at a time. Every step builds on the one before, and the code is in [`examples/guide.ts`](../examples/guide.ts), where it is type-checked and run by a test.
|
|
4
|
+
|
|
5
|
+
## 1. A bus
|
|
6
|
+
|
|
7
|
+
`on` subscribes, `emit` calls the subscribers, and `on` returns the function that unsubscribes, as in nanoevents.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createBus } from "nanoeffects";
|
|
11
|
+
|
|
12
|
+
const bus = createBus();
|
|
13
|
+
|
|
14
|
+
const unbind = bus.on("greet", (name) => console.log(`hello ${name}`));
|
|
15
|
+
|
|
16
|
+
bus.emit("greet", "Ada"); // hello Ada
|
|
17
|
+
unbind();
|
|
18
|
+
bus.emit("greet", "Bob"); // nobody listens now
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The handlers run at once, in the order they subscribed. That is all a bus is; the rest of the guide is what you can put on top.
|
|
22
|
+
|
|
23
|
+
## 2. Types
|
|
24
|
+
|
|
25
|
+
Describe the events once, and `on` and `emit` are checked. An event without data is `void`.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
type Events = {
|
|
29
|
+
greet: string;
|
|
30
|
+
moved: { x: number; y: number };
|
|
31
|
+
reset: void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const bus = createBus<Events>();
|
|
35
|
+
|
|
36
|
+
bus.on("moved", ({ x, y }) => console.log(x + y));
|
|
37
|
+
bus.emit("moved", { x: 1, y: 2 });
|
|
38
|
+
bus.emit("reset");
|
|
39
|
+
bus.emit("moved", { x: "1" }); // error: x is a number
|
|
40
|
+
bus.emit("jumped", 1); // error: there is no such event
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The map is optional: a bus without it takes any string and gives the payload as `unknown`.
|
|
44
|
+
|
|
45
|
+
## 3. The context of a handler
|
|
46
|
+
|
|
47
|
+
The second argument is the context of this call. A handler can emit events through it.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
bus.on("moved", ({ x, y }, ctx) => {
|
|
51
|
+
if (x < 0) ctx.emit("outOfBounds", { x, y });
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Each call of a handler is a **task**, and `ctx` is what the task knows about itself: its `signal`, a way to `abort` it, and `emit`, `on`, `run`. A task that is aborted stops emitting.
|
|
56
|
+
|
|
57
|
+
## 4. Three ways to name an event
|
|
58
|
+
|
|
59
|
+
`emit` and `on` take the event as a string, as an object `{ type, payload }`, or as a function that has a `type` property.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
bus.emit("moved", { x: 1, y: 2 }); // a string, and the payload next to it
|
|
63
|
+
|
|
64
|
+
bus.emit({ type: "moved", payload: { x: 2, y: 2 } }); // an object, the shape of a Redux action
|
|
65
|
+
|
|
66
|
+
const moved = createAction("moved");
|
|
67
|
+
bus.emit(moved({ x: 3, y: 2 })); // what an action creator makes is such an object
|
|
68
|
+
bus.on(moved, ({ x }) => console.log(x)); // and it names the event in `on` too
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The third way is a function. A function with a `type` is a **command**: `emit` tells the subscribers of its type, then runs it, and **returns what it returns**.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { createAction, createCommand } from "nanoeffects/actions";
|
|
75
|
+
|
|
76
|
+
const double = createCommand("math/double", (value: number) => value * 2);
|
|
77
|
+
|
|
78
|
+
bus.emit(double, 21); // 42
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Strings are for the plain case, objects for actions that other code already makes, functions for requests that need a result.
|
|
82
|
+
|
|
83
|
+
## 5. A command needs a handler
|
|
84
|
+
|
|
85
|
+
A command made with a function carries its handler, as above. More often it is only a contract, a name with the types of the request and the result, and the code that does the work is registered separately: in another file, or a fake one in a test.
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { createCommand, createCommands } from "nanoeffects/actions";
|
|
89
|
+
|
|
90
|
+
const getUser = createCommand<string, Promise<User>>("user/get"); // no handler yet
|
|
91
|
+
|
|
92
|
+
const bus = createBus<Events>();
|
|
93
|
+
const commands = createCommands(bus); // the handlers of this bus
|
|
94
|
+
|
|
95
|
+
bus.emit(getUser, "1"); // throws: No handler for command "user/get"
|
|
96
|
+
|
|
97
|
+
const unregister = commands.handle(getUser, (id) => api.user(id));
|
|
98
|
+
const user = await bus.emit(getUser, "1"); // what the handler returned
|
|
99
|
+
|
|
100
|
+
unregister(); // the command has no handler again
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- `handle` returns the function that removes the handler, like `on`.
|
|
104
|
+
- If a command has several handlers, the one registered last runs. A handler registered here wins over the one the command was made with.
|
|
105
|
+
- Each bus has its own registry, so every test can bring its own handlers.
|
|
106
|
+
- A [module](#10-modules) registers handlers for itself and removes them when it is unmounted.
|
|
107
|
+
|
|
108
|
+
## 6. The first policy
|
|
109
|
+
|
|
110
|
+
Often a handler is called again before the last call has finished: a user types another letter, a button is clicked twice. A **policy** wraps the handler and decides what happens then. It is used in place of the handler:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { latest } from "nanoeffects/policies";
|
|
114
|
+
|
|
115
|
+
// a newer search aborts the older one
|
|
116
|
+
bus.on(
|
|
117
|
+
"search",
|
|
118
|
+
latest(async (query, { signal }) => {
|
|
119
|
+
await api.search(query, { signal });
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The aborted call learns about it through `signal`: pass it to `fetch` and the request is cancelled. Nothing else changes: the handler is the same function with the same arguments.
|
|
125
|
+
|
|
126
|
+
## 7. More policies
|
|
127
|
+
|
|
128
|
+
| Policy | When calls overlap |
|
|
129
|
+
| ---------- | ------------------------------------------- |
|
|
130
|
+
| `latest` | the previous call is aborted |
|
|
131
|
+
| `leading` | new calls are dropped while one runs |
|
|
132
|
+
| `debounce` | runs after a pause in the calls |
|
|
133
|
+
| `throttle` | runs at most once in a time window |
|
|
134
|
+
| `queue` | one after another, in order |
|
|
135
|
+
|
|
136
|
+
Even a plain function works, it is just called less often. And policies nest:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { debounce, queue, throttle } from "nanoeffects/policies";
|
|
140
|
+
|
|
141
|
+
bus.on("scroll", throttle(100, (position) => updateScrollbar(position)));
|
|
142
|
+
bus.on("typed", debounce(300, (text) => validate(text)));
|
|
143
|
+
bus.on("save", queue(async (draft, { signal }) => api.save(draft, { signal })));
|
|
144
|
+
|
|
145
|
+
// wait for quiet, then keep only the latest
|
|
146
|
+
bus.on("search", debounce(300, latest(async (query, { signal }) => api.search(query, { signal }))));
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
More in [policies](policies.md).
|
|
150
|
+
|
|
151
|
+
## 8. The first effects
|
|
152
|
+
|
|
153
|
+
An **effect** adds what an async handler usually needs. `retry` and `timeout` wrap a handler the way a policy does, so they go into `on` with the policies and need no `ctx`:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { retry, timeout } from "nanoeffects/effects";
|
|
157
|
+
|
|
158
|
+
const search = (query: string, { signal }) => api.search(query, { signal });
|
|
159
|
+
|
|
160
|
+
// 5 seconds for each try, up to 3 tries, and a newer query supersedes the whole thing
|
|
161
|
+
bus.on("typed", latest(retry(timeout(5000, search), { times: 3 })));
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
When the handler needs the result, `ctx.run` runs the wrapped function as a child task: it is aborted with the handler, and it is the one place where `ctx` is written.
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
bus.on(
|
|
168
|
+
"search",
|
|
169
|
+
latest(async (query, ctx) => {
|
|
170
|
+
const items = await ctx.run(retry(timeout(5000, search), { times: 3 }), query);
|
|
171
|
+
ctx.emit("results", items);
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
For one call in the middle of a handler, the effect takes `ctx` first: `await retry(ctx, () => api.search(q, { signal: ctx.signal }))`. More in [effects](effects.md).
|
|
177
|
+
|
|
178
|
+
## 9. Several things at once, and waiting
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { all, take } from "nanoeffects/effects";
|
|
182
|
+
|
|
183
|
+
bus.on("search", async (id, ctx) => {
|
|
184
|
+
const { user, posts } = await all(ctx, {
|
|
185
|
+
user: ({ signal }) => api.user(id, { signal }),
|
|
186
|
+
posts: ({ signal }) => api.posts(id, { signal }),
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
bus.on("typed", async (query, ctx) => {
|
|
191
|
+
const position = await take(ctx, "scroll"); // resolves with the next scroll
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`all` runs the starters together and fails, and cancels the rest, when one fails. `take` waits for the next event of a name. `ctx.on` subscribes as the task and ends with it. More in [effects](effects.md).
|
|
196
|
+
|
|
197
|
+
## 10. Modules
|
|
198
|
+
|
|
199
|
+
A module is what one part of the app does, in one place: its handlers, its commands, its background tasks. Unmounting it undoes all of that.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { createModule, modulePlugin } from "nanoeffects/actions";
|
|
203
|
+
|
|
204
|
+
const greeting = createModule<Events>("greeting", (m) => {
|
|
205
|
+
m.on("greet", (name) => console.log(`hello ${name}`));
|
|
206
|
+
m.handle(getUser, (id) => api.user(id)); // a contract gets its handler here
|
|
207
|
+
m.use(double); // a command that carries a handler: the module brings it
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const app = bus.use(modulePlugin([greeting]));
|
|
211
|
+
app.unmount(greeting); // the subscriptions and the handler are gone
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Modules are also what makes [hot reload](hmr.md) work. More in [actions](actions.md).
|
|
215
|
+
|
|
216
|
+
## 11. Errors and typos
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const bus = createBus<Events>({
|
|
220
|
+
onError: (error) => report(error), // a failing handler does not stop the others
|
|
221
|
+
onUnhandled: ({ type }) => console.warn(`nobody listens to ${type}`), // a typo in a name
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
More in [errors](errors.md).
|
|
226
|
+
|
|
227
|
+
## Where next
|
|
228
|
+
|
|
229
|
+
[Context fields](context.md) (a store or a client in every task) · [Tracing](tracing.md) · [Redux, Zustand, MobX](stores.md) · [Semantics](semantics.md)
|
package/docs/hmr.md
CHANGED
|
@@ -1,74 +1,36 @@
|
|
|
1
|
-
# Hot reload
|
|
1
|
+
# Hot reload and development diagnostics
|
|
2
2
|
|
|
3
|
-
When a file is edited in development, the bundler loads its new version and runs it again, while the page and the rest of the code stay alive. For a bus that
|
|
4
|
-
|
|
5
|
-
1. **Handlers pile up.** The file that calls `bus.on(...)` runs again, and the old handlers are still subscribed: every event is handled twice, then three times.
|
|
6
|
-
2. **The bus is made again.** A file that has `createBus()` runs again: the new bus has none of the subscriptions of the files that did not reload.
|
|
7
|
-
3. **Old work goes on.** Tasks that the old code started keep running with the old logic.
|
|
8
|
-
|
|
9
|
-
Tasks that are tied to a signal can be ended, and modules are the unit that does it, so the answer is modules.
|
|
3
|
+
When a file is edited in development, the bundler loads its new version and runs it again, while the page and the rest of the code stay alive. For a bus that can go wrong in three ways: a file that calls `bus.on(...)` subscribes a second time, a file that makes the bus makes a new one, and tasks of the old code keep running.
|
|
10
4
|
|
|
11
5
|
## Modules are replaced by name
|
|
12
6
|
|
|
13
|
-
Mounting a module whose name is taken replaces the mounted one
|
|
7
|
+
Mounting a module whose name is taken replaces the mounted one. The old module is unmounted first, and with it its subscriptions, its bound commands, its sub-modules and its background tasks, whose signals abort. So the new version of a module file only has to mount again, and nothing piles up.
|
|
14
8
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
- **Names must be unique.** The name says "this is the new version of that module", so two different modules with one name replace each other. The name is also the type of the module's tasks in the trace.
|
|
10
|
+
- **Pass `signal` to what the module does.** An unmount aborts the tasks, but a `fetch` or a loop that ignores `ctx.signal` runs on with the old code.
|
|
11
|
+
- **Sub-modules** (`m.use(...)`) are replaced by name inside their parent. A reload of the parent module replaces it with all its children.
|
|
12
|
+
- Keep the bus and the app in a file that is not reloaded, or in what your bundler keeps across reloads, so a reload does not make a new bus that the other files do not talk to.
|
|
19
13
|
|
|
20
|
-
|
|
14
|
+
A `bus.on` outside a module returns the function that unsubscribes: hand it to the dispose hook of your bundler, or put the subscription in a module.
|
|
21
15
|
|
|
22
|
-
|
|
23
|
-
export const app: ModuleApi<Events> = hot?.data.app ?? registerModules(bus);
|
|
24
|
-
if (hot) {
|
|
25
|
-
hot.data.bus = bus;
|
|
26
|
-
hot.data.app = app;
|
|
27
|
-
}
|
|
28
|
-
```
|
|
16
|
+
## `nanoeffects/dev`
|
|
29
17
|
|
|
30
|
-
|
|
18
|
+
`bus.use(devPlugin({ app }))` reports what is easy to miss while developing:
|
|
31
19
|
|
|
32
20
|
```ts
|
|
33
|
-
|
|
34
|
-
export const userModule = createModule<Events>("user", (m) => {
|
|
35
|
-
m.handle(getUser, fetchUser);
|
|
36
|
-
m.on("login", latest(loadProfile));
|
|
37
|
-
});
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
```ts
|
|
41
|
-
// main.ts: mounts the modules, and takes the new version of a module on reload
|
|
42
|
-
import { app } from "./bus";
|
|
43
|
-
import { userModule } from "./user.module";
|
|
44
|
-
|
|
45
|
-
app.mount(userModule);
|
|
21
|
+
import { devPlugin } from "nanoeffects/dev";
|
|
46
22
|
|
|
47
|
-
|
|
48
|
-
|
|
23
|
+
const app = bus.use(modulePlugin([userModule]));
|
|
24
|
+
bus.use(devPlugin({ app }));
|
|
49
25
|
```
|
|
50
26
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
- **Keep the bus and the app in `import.meta.hot.data`** (or in a file that never reloads), as above. Otherwise a reload of that file makes a new bus, and the modules that did not reload talk to the old one.
|
|
56
|
-
- **Names must be unique.** The name is what says "this is the new version of that module", so two different modules with one name replace each other. The name is also the type of the module's tasks in the trace.
|
|
57
|
-
- **Pass `signal` to what the module does.** An unmount aborts the tasks, but a `fetch` or a loop that ignores `ctx.signal` runs on. This is the same advice as everywhere else, and in development it also stops old code from writing to the new state.
|
|
58
|
-
- **Sub-modules** (`m.use(...)`) are replaced by name inside their parent. A reload of the parent module replaces it with all its children.
|
|
59
|
-
|
|
60
|
-
## Handlers without modules
|
|
61
|
-
|
|
62
|
-
A plain `bus.on` returns the function that unsubscribes. Give it to the dispose hook of the file:
|
|
63
|
-
|
|
64
|
-
```ts
|
|
65
|
-
if (import.meta.hot) {
|
|
66
|
-
import.meta.hot.dispose(bus.on("search", latest(runSearch)));
|
|
67
|
-
}
|
|
68
|
-
```
|
|
27
|
+
- **Listeners that pile up.** When more than `maxListeners` (default 10) listeners of one event were added and not unsubscribed, one warning names the event and the count: a file that is reloaded and subscribes again, or an unsubscribe that is never called. The count follows the function that `on` returns, and `bus.off(event, fn)` is not seen.
|
|
28
|
+
- **Modules.** With `app` it logs `module "user" mounted`, `replaced` when a module of the same name was mounted, and `unmounted`. Only what goes through `app.mount` and `app.unmount` is reported, not sub-modules.
|
|
29
|
+
- **Options.** `leaks: false` and `modules: false` switch a diagnostic off, `maxListeners` sets the limit, `log` receives the messages instead of the console.
|
|
30
|
+
- **Disconnect.** `bus.use(devPlugin(...))` returns the function that stops the diagnostics and puts `bus.on` and `app.mount` back.
|
|
69
31
|
|
|
70
|
-
|
|
32
|
+
It changes no behavior: it wraps `bus.on`, `app.mount` and `app.unmount` and adds messages.
|
|
71
33
|
|
|
72
|
-
|
|
34
|
+
### In production
|
|
73
35
|
|
|
74
|
-
|
|
36
|
+
`nanoeffects/dev` is a no-op in a production build. `package.json` maps it to an empty build under the `production` export condition, which Vite and webpack set in production mode, so `bus.use(devPlugin({ app }))` can stay without a guard: it touches nothing and costs a few bytes. If you want even the call gone, guard it (`if (import.meta.env.DEV)` in Vite). An app that does not import the entry does not carry it.
|
package/docs/policies.md
CHANGED
|
@@ -37,10 +37,10 @@ The state of a key is dropped when that key goes idle, so keys can be unbounded
|
|
|
37
37
|
import { queue } from "nanoeffects/policies";
|
|
38
38
|
|
|
39
39
|
bus.on(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
"save",
|
|
41
|
+
queue(async (draft, { signal }) => {
|
|
42
|
+
await api.save(draft, { signal });
|
|
43
|
+
}),
|
|
44
44
|
);
|
|
45
45
|
|
|
46
46
|
bus.emit("save", a); // runs now
|
package/docs/stores.md
CHANGED
|
@@ -27,8 +27,6 @@ It looks after what is easy to get wrong:
|
|
|
27
27
|
|
|
28
28
|
Options are those of `createBus` (except `context`, which adds fields next to `store`) and `filter`, which keeps the actions it rejects away from the bus. Values that are not plain actions (thunks) go through untouched. Nothing here depends on Redux.
|
|
29
29
|
|
|
30
|
-
To use your own bus, `nanoeffectsMiddleware(bus, { filter })` is the middleware alone: it sends actions to the bus after the reducers and nothing more. Give the store to the tasks yourself with `createBus({ context: { store } })`, or lazily, `context: { get store() { return store; } }` (typed with `WithContext`), since the store needs the middleware and the middleware needs the bus.
|
|
31
|
-
|
|
32
30
|
## Zustand, MobX and other stores
|
|
33
31
|
|
|
34
32
|
No adapter is needed: a store tells about its changes, and `bus.emit` is a call.
|
package/docs/tracing.md
CHANGED
|
@@ -9,7 +9,7 @@ import { createBus } from "nanoeffects";
|
|
|
9
9
|
import { createTracer } from "nanoeffects/trace";
|
|
10
10
|
|
|
11
11
|
const bus = createBus<Events>({
|
|
12
|
-
|
|
12
|
+
trace: createTracer((event) => console.log(event)),
|
|
13
13
|
});
|
|
14
14
|
```
|
|
15
15
|
|
|
@@ -36,7 +36,7 @@ Phases:
|
|
|
36
36
|
|
|
37
37
|
```ts
|
|
38
38
|
bus.on("load", async (id, ctx) => {
|
|
39
|
-
|
|
39
|
+
await all(ctx, { user: () => fetchUser(id), posts: () => fetchPosts(id) });
|
|
40
40
|
});
|
|
41
41
|
```
|
|
42
42
|
|
|
@@ -49,7 +49,7 @@ import { createBus } from "nanoeffects";
|
|
|
49
49
|
import { consoleLogger, createTracer, reduxDevtools } from "nanoeffects/trace";
|
|
50
50
|
|
|
51
51
|
const bus = createBus<Events>({
|
|
52
|
-
|
|
52
|
+
trace: createTracer([reduxDevtools({ name: "app" }), consoleLogger()]),
|
|
53
53
|
});
|
|
54
54
|
```
|
|
55
55
|
|
|
@@ -57,7 +57,7 @@ const bus = createBus<Events>({
|
|
|
57
57
|
|
|
58
58
|
| Logger | What it does |
|
|
59
59
|
| ----------------------- | ------------------------------------------------------------------------------------ |
|
|
60
|
-
| `reduxDevtools()` | Sends
|
|
60
|
+
| `reduxDevtools()` | Sends tasks to Redux DevTools as `type/effect/phase`; state is `{ running }`. |
|
|
61
61
|
| `consoleLogger()` | Prints one line per phase, indented by task depth. |
|
|
62
62
|
| `performanceLogger()` | Records User Timing measures for the Timings track of the browser Performance panel. |
|
|
63
63
|
| `createTraceRecorder()` | Keeps events in `recorder.events`, for tests. |
|
|
@@ -68,15 +68,28 @@ const bus = createBus<Events>({
|
|
|
68
68
|
|
|
69
69
|
```ts
|
|
70
70
|
createTracer(logger, {
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
sample: 0.05, // trace 5% of the handler calls (a share, or a function of { type, payload })
|
|
72
|
+
filter: (event) => event.phase === "error", // and keep only failures
|
|
73
73
|
});
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
- `sample` is decided once per root task (one handler call) and every task below it follows, so a trace is never cut in the middle. Tasks that are not sampled cost almost nothing: their effects run without tracing.
|
|
77
77
|
- `filter` runs on each event before any logger sees it.
|
|
78
78
|
|
|
79
|
-
Notes on `reduxDevtools`: it is read-only (the bus owns no state, so there is no time travel) and does nothing when the extension is not installed.
|
|
79
|
+
Notes on `reduxDevtools`: it is read-only (the bus owns no state, so there is no time travel) and does nothing when the extension is not installed. It has its own instance in the DevTools picker, apart from your store.
|
|
80
|
+
|
|
81
|
+
It is quiet by default, so the log stays readable:
|
|
82
|
+
|
|
83
|
+
- **A task is one action**, sent when it ends (`done`, `error`, `cancel`, `drop`). The tasks that are running are in the state, `{ running }`. `starts: true` also sends every start.
|
|
84
|
+
- **Only the top is shown**: the handler tasks and the effects they start (`retry`, `timeout`, ...). The attempts and starters under them are hidden. `depth: 0` shows the handler tasks only, `depth: 3` more, `Infinity` everything.
|
|
85
|
+
- `maxAge: 200` sets how many actions DevTools keeps (its own default is 50).
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
reduxDevtools({ name: "app", depth: 0 }); // one line per handler call
|
|
89
|
+
reduxDevtools({ name: "app", starts: true, depth: Infinity }); // everything, for a hunt for a bug
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
For more control, `createTracer(logger, { filter, sample })` cuts events before any logger sees them.
|
|
80
93
|
|
|
81
94
|
To write your own, return a `TraceLogger`:
|
|
82
95
|
|
|
@@ -84,7 +97,7 @@ To write your own, return a `TraceLogger`:
|
|
|
84
97
|
import type { TraceLogger } from "nanoeffects";
|
|
85
98
|
|
|
86
99
|
const errorsOnly = (): TraceLogger => (event) => {
|
|
87
|
-
|
|
100
|
+
if (event.phase === "error") report(event.error);
|
|
88
101
|
};
|
|
89
102
|
```
|
|
90
103
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nanoeffects",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Typed event bus where every handler is a task with an AbortSignal, plus policies (latest, debounce, throttle, queue), effects (retry, timeout, poll) and tracing. About 1.2 kB gzip.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"event-bus",
|
|
@@ -87,21 +87,31 @@
|
|
|
87
87
|
"./observable": {
|
|
88
88
|
"types": "./dist/observable.d.ts",
|
|
89
89
|
"import": "./dist/observable.js"
|
|
90
|
+
},
|
|
91
|
+
"./dev": {
|
|
92
|
+
"production": {
|
|
93
|
+
"types": "./dist/dev.d.ts",
|
|
94
|
+
"default": "./dist/production/dev.js"
|
|
95
|
+
},
|
|
96
|
+
"types": "./dist/dev.d.ts",
|
|
97
|
+
"import": "./dist/dev.js"
|
|
90
98
|
}
|
|
91
99
|
},
|
|
92
100
|
"scripts": {
|
|
93
101
|
"build": "tsdown && node scripts/notrace-types.mjs",
|
|
94
102
|
"prepublishOnly": "pnpm build",
|
|
95
|
-
"check": "biome check ./src ./examples ./integration",
|
|
96
|
-
"format": "npx @biomejs/biome check --write --unsafe ./src ./examples ./integration",
|
|
103
|
+
"check": "biome check ./src ./examples ./integration ./scripts",
|
|
104
|
+
"format": "npx @biomejs/biome check --write --unsafe ./src ./examples ./integration ./scripts",
|
|
97
105
|
"tsgo": "tsc --noEmit",
|
|
98
106
|
"test": "vitest run",
|
|
99
107
|
"test:watch": "vitest",
|
|
100
108
|
"prepare": "lefthook install",
|
|
101
109
|
"lint:package": "publint && attw --pack . --profile esm-only",
|
|
102
110
|
"size": "size-limit",
|
|
103
|
-
"verify": "pnpm tsgo && pnpm check && pnpm test && pnpm build && pnpm test:integration && pnpm lint:package && pnpm size",
|
|
104
|
-
"test:integration": "vitest run --config vitest.integration.config.ts"
|
|
111
|
+
"verify": "pnpm tsgo && pnpm check && pnpm test && pnpm build && pnpm test:integration && pnpm smoke && pnpm lint:package && pnpm size",
|
|
112
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
113
|
+
"smoke": "node scripts/smoke.mjs && node scripts/smoke.mjs --notrace",
|
|
114
|
+
"consumer-check": "node scripts/consumer-check.mjs"
|
|
105
115
|
},
|
|
106
116
|
"devDependencies": {
|
|
107
117
|
"@arethetypeswrong/cli": "^0.18.5",
|
|
@@ -160,6 +170,16 @@
|
|
|
160
170
|
"name": "observable",
|
|
161
171
|
"path": "dist/observable.js",
|
|
162
172
|
"limit": "0.5 kB"
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"name": "dev",
|
|
176
|
+
"path": "dist/dev.js",
|
|
177
|
+
"limit": "1 kB"
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"name": "dev (production)",
|
|
181
|
+
"path": "dist/production/dev.js",
|
|
182
|
+
"limit": "100 B"
|
|
163
183
|
}
|
|
164
184
|
]
|
|
165
185
|
}
|
package/dist/bus-RUiw8f9p.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{n as e}from"./onAbort-BgMfspK4.js";import{a as t,n,r}from"./context-BXQ6Einm.js";import{n as i}from"./promise-CSLpzwSR.js";import{n as a,t as o}from"./event-0RIBGYqS.js";const s=`*`,c=()=>{let e={},t=/* @__PURE__ */ new WeakSet,n=n=>{let r=e[n]??[];if(!t.has(r))return e[n]=r,r;let i=[...r];return e[n]=i,i},r=(t,r)=>{if(!r){e[t]=[];return}let i=(e[t]??[]).indexOf(r);i>=0&&n(t).splice(i,1)};return{get:n=>{let r=e[n]??[];return t.add(r),r},add:(e,t)=>(n(e).push(t),()=>{r(e,t)}),remove:r}},l=(e,t,n)=>Reflect.apply(e,null,[t,n]),u=(s={})=>{let u=c(),d=(e,t)=>u.add(o(e),t),f=(e,t)=>u.remove(o(e),t),p=(t,a)=>{let{onError:o}=s,{ctx:c}=n(a,m,s.trace,{ref:r()});if(!o){t(a.payload,c);return}let l=t=>{try{o(t,a)}catch(t){e(`onError failed`,t)}};try{let e=t(a.payload,c);i(e)&&Promise.resolve(e).catch(l)}catch(e){l(e)}},m={on:d,off:f,emit:(e,i)=>{let c=o(e),d=a(e,i),f={type:c,payload:d},h=[...u.get(c),...u.get(`*`)];h.length===0&&typeof e!=`function`&&s.onUnhandled?.(f);for(let e of h)p(e,f);if(typeof e==`function`){let{ctx:i}=n(f,m,s.trace,{ref:r()}),a=m.commands?.run??l;return t(i,`command`,()=>a(e,d,i))}},commands:s.commands,context:s.context,trace:s.trace};return m};export{s as n,u as t};
|