nanoeffects 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +96 -2
- package/dist/action-rqZEnRIp.d.ts +303 -0
- package/dist/actions.d.ts +58 -0
- package/dist/actions.js +1 -0
- package/dist/bus-CRnlwmQ4.js +1 -0
- package/dist/context-DlA80GOA.js +1 -0
- package/dist/delay-CIHnOh_L.js +1 -0
- package/dist/dev.d.ts +46 -0
- package/dist/dev.js +1 -0
- package/dist/effects.d.ts +284 -0
- package/dist/effects.js +1 -0
- package/dist/event-0RIBGYqS.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/module-BSCdR9D1.d.ts +167 -0
- package/dist/notrace/action-rqZEnRIp.d.ts +303 -0
- package/dist/notrace/actions.d.ts +222 -0
- package/dist/notrace/actions.js +1 -0
- package/dist/notrace/bus-Dx0l9X6h.js +1 -0
- package/dist/notrace/context-yAWj0BFZ.js +1 -0
- package/dist/notrace/delay-Cgvn16oY.js +1 -0
- package/dist/notrace/effects.d.ts +284 -0
- package/dist/notrace/effects.js +1 -0
- package/dist/notrace/event-0RIBGYqS.js +1 -0
- package/dist/notrace/index.d.ts +2 -0
- package/dist/notrace/index.js +1 -0
- package/dist/notrace/policies.d.ts +171 -0
- package/dist/notrace/policies.js +1 -0
- package/dist/notrace/promise-CSLpzwSR.js +1 -0
- package/dist/notrace/redux.d.ts +92 -0
- package/dist/notrace/redux.js +1 -0
- package/dist/observable.d.ts +20 -0
- package/dist/observable.js +1 -0
- package/dist/onAbort-BgMfspK4.js +1 -0
- package/dist/policies.d.ts +171 -0
- package/dist/policies.js +1 -0
- package/dist/production/dev.js +1 -0
- package/dist/promise-CSLpzwSR.js +1 -0
- package/dist/redux.d.ts +92 -0
- package/dist/redux.js +1 -0
- package/dist/trace.d.ts +162 -0
- package/dist/trace.js +1 -0
- package/docs/README.md +15 -0
- package/docs/actions.md +78 -0
- package/docs/context.md +15 -0
- package/docs/effects.md +137 -0
- package/docs/errors.md +23 -0
- package/docs/guide.md +229 -0
- package/docs/hmr.md +36 -0
- package/docs/observable.md +22 -0
- package/docs/policies.md +53 -0
- package/docs/semantics.md +7 -0
- package/docs/stores.md +50 -0
- package/docs/tracing.md +117 -0
- package/package.json +184 -5
package/docs/effects.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Effects and cancellation
|
|
2
|
+
|
|
3
|
+
An abort only raises a flag: your code decides to stop. `nanoeffects/effects` has helpers for the usual cases.
|
|
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:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { cancellable } from "nanoeffects/effects";
|
|
9
|
+
|
|
10
|
+
bus.on(
|
|
11
|
+
"wait",
|
|
12
|
+
latest((_, ctx) =>
|
|
13
|
+
cancellable(ctx, ({ resolve, onCancel }) => {
|
|
14
|
+
const timer = setTimeout(resolve, 5000);
|
|
15
|
+
onCancel(() => clearTimeout(timer));
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
);
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- If the task aborts before the promise settles, cleanups run (last registered first) and the promise rejects with the abort reason.
|
|
22
|
+
- If the task is already aborted, the executor does not run.
|
|
23
|
+
- After the promise settles, the abort listener is removed and cleanups are dropped.
|
|
24
|
+
|
|
25
|
+
`onAbort(ctx, fn)` runs `fn(reason)` on abort (at once if already aborted) and returns a function that unsubscribes:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { onAbort } from "nanoeffects/effects";
|
|
29
|
+
|
|
30
|
+
bus.on("connect", (url, ctx) => {
|
|
31
|
+
const socket = new WebSocket(url);
|
|
32
|
+
onAbort(ctx, () => socket.close());
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
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.
|
|
39
|
+
|
|
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
|
+
```
|
|
48
|
+
|
|
49
|
+
## Subscribing from a task: ctx.on, ctx.off, take
|
|
50
|
+
|
|
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:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
bus.on("watch", (_, ctx) => {
|
|
55
|
+
ctx.on("tick", (n) => console.log(n)); // gone when this task aborts
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
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.
|
|
62
|
+
|
|
63
|
+
`take(ctx, event, options?)` waits for the next `event` and resolves with its payload:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { take, timeout } from "nanoeffects/effects";
|
|
67
|
+
|
|
68
|
+
bus.on("login", async (user, ctx) => {
|
|
69
|
+
const reason = await take(ctx, "logout"); // resolves on the next logout
|
|
70
|
+
cleanup(user, reason);
|
|
71
|
+
|
|
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
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
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
|
|
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
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { poll, retry, timeout } from "nanoeffects/effects";
|
|
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
|
|
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);
|
|
117
|
+
});
|
|
118
|
+
```
|
|
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
|
+
|
|
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
|
+
```
|
|
132
|
+
|
|
133
|
+
- All three are traced: the effect is a group (`retry`, `timeout`, `poll`) and each attempt or call is a `run` child.
|
|
134
|
+
|
|
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`.
|
|
136
|
+
|
|
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
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Errors and unheard events
|
|
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.
|
|
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`.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
const bus = createBus<Events>({
|
|
9
|
+
onError: (error, { type, payload }) => report(error, type, payload),
|
|
10
|
+
});
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Events that nobody listens to
|
|
14
|
+
|
|
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:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const bus = createBus<Events>({
|
|
19
|
+
onUnhandled: import.meta.env.DEV
|
|
20
|
+
? ({ type }) => console.warn(`Nobody listens to "${type}"`)
|
|
21
|
+
: void 0,
|
|
22
|
+
});
|
|
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
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Hot reload and development diagnostics
|
|
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 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.
|
|
4
|
+
|
|
5
|
+
## Modules are replaced by name
|
|
6
|
+
|
|
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.
|
|
8
|
+
|
|
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.
|
|
13
|
+
|
|
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.
|
|
15
|
+
|
|
16
|
+
## `nanoeffects/dev`
|
|
17
|
+
|
|
18
|
+
`bus.use(devPlugin({ app }))` reports what is easy to miss while developing:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { devPlugin } from "nanoeffects/dev";
|
|
22
|
+
|
|
23
|
+
const app = bus.use(modulePlugin([userModule]));
|
|
24
|
+
bus.use(devPlugin({ app }));
|
|
25
|
+
```
|
|
26
|
+
|
|
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.
|
|
31
|
+
|
|
32
|
+
It changes no behavior: it wraps `bus.on`, `app.mount` and `app.unmount` and adds messages.
|
|
33
|
+
|
|
34
|
+
### In production
|
|
35
|
+
|
|
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.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Observable: events as streams
|
|
2
|
+
|
|
3
|
+
`createObserve(bus)` from `nanoeffects/observable` turns an event of the bus into an Observable that follows the TC39 proposal (`subscribe`, `unsubscribe`, `Symbol.observable`). RxJS and other stream libraries take it as it is, and nothing here depends on them.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { from } from "rxjs";
|
|
7
|
+
import { debounceTime, distinctUntilChanged, switchMap } from "rxjs/operators";
|
|
8
|
+
import { createObserve } from "nanoeffects/observable";
|
|
9
|
+
|
|
10
|
+
const observe = createObserve(bus);
|
|
11
|
+
|
|
12
|
+
// events of the bus as a stream
|
|
13
|
+
from(observe("typed"))
|
|
14
|
+
.pipe(debounceTime(300), distinctUntilChanged(), switchMap(searchRequest))
|
|
15
|
+
.subscribe((items) => bus.emit("results", items));
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- `observe(event)` returns an Observable of the payloads of that event. Each `subscribe` is a `bus.on` of its own, and `unsubscribe()` removes it.
|
|
19
|
+
- `subscribe` takes a function or an object with `next`.
|
|
20
|
+
- The stream never completes and never fails: events are the only thing that arrives. Errors of the handler of the subscriber belong to the subscriber.
|
|
21
|
+
- A subscription made this way is not tied to a task, so end it yourself: `onAbort(ctx, subscription.unsubscribe)` inside a task, or with the signal of a module.
|
|
22
|
+
- For cancellation, policies and tracing keep to `bus.on` with `latest`, `debounce`, `queue`: they know about tasks. The Observable is for the places where a stream is already the way you work.
|
package/docs/policies.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Policies
|
|
2
|
+
|
|
3
|
+
A policy wraps a handler and decides what happens when calls overlap. Every policy takes `key` in its options (see below), so each key has its own state.
|
|
4
|
+
|
|
5
|
+
| Policy | Overlapping calls |
|
|
6
|
+
| ------------------------------ | -------------------------------------------------------------------------- |
|
|
7
|
+
| `latest(fn)` | the previous run is aborted and its result dropped |
|
|
8
|
+
| `leading(fn)` | new calls are dropped while a run is going |
|
|
9
|
+
| `debounce(ms, fn, options?)` | run after `ms` of quiet |
|
|
10
|
+
| `throttle(ms, fn, options?)` | at most one run per `ms` |
|
|
11
|
+
| `queue(fn, options?)` | one after another, in order |
|
|
12
|
+
|
|
13
|
+
`debounce` and `throttle` take `leading` and `trailing`:
|
|
14
|
+
|
|
15
|
+
- `throttle(ms, fn)` works like lodash `throttle` (and patronum): a call made when the throttle is idle, at least `ms` after the previous call and the previous run, runs at once (`leading`, default `true`); calls made while it waits are held, and the latest of them runs when the window closes (`trailing`, default `true`). Under a steady stream of calls it runs every `ms`. `leading: false` makes even the first call wait for the end of its window; `trailing: false` drops the calls made during a window.
|
|
16
|
+
- `debounce(ms, fn)` runs the last call after `ms` of quiet (`trailing`, default `true`). `leading: true` also runs the first call of a burst at once, and then the trailing run happens only if more calls follow it. `leading: true, trailing: false` runs only the first call of a burst.
|
|
17
|
+
- With both options `false` nothing runs.
|
|
18
|
+
- `throttle` and `debounce` are checked against lodash itself: the tests feed random sequences of calls to both and compare the moments `fn` runs.
|
|
19
|
+
|
|
20
|
+
`key` gives each key its own state, so calls with different keys do not affect each other:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// one latest run per user instead of one for all of them
|
|
24
|
+
bus.on("load", latest(loadUser, { key: (user) => user.id }));
|
|
25
|
+
|
|
26
|
+
// uploads of the same file go one after another, different files in parallel
|
|
27
|
+
bus.on("upload", queue(upload, { key: (file) => file.name }));
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The state of a key is dropped when that key goes idle, so keys can be unbounded (ids, urls).
|
|
31
|
+
|
|
32
|
+
## Queue
|
|
33
|
+
|
|
34
|
+
`queue(fn, options?)` runs the calls of a handler one after another, in the order they arrived, instead of dropping or replacing them like `leading` and `latest` do:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { queue } from "nanoeffects/policies";
|
|
38
|
+
|
|
39
|
+
bus.on(
|
|
40
|
+
"save",
|
|
41
|
+
queue(async (draft, { signal }) => {
|
|
42
|
+
await api.save(draft, { signal });
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
bus.emit("save", a); // runs now
|
|
47
|
+
bus.emit("save", b); // waits for a, then runs
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- `concurrency` (default 1) is how many calls may run at the same time.
|
|
51
|
+
- `limit` caps how many calls may wait, and `overflow` says what to drop when the line is full: the new call (`"drop-new"`, the default) or the oldest waiting one (`"drop-old"`). A dropped call resolves with `undefined`. `queue(fn, { limit: 1, overflow: "drop-old" })` keeps only the latest call while one is running and runs it when the current one is done; `limit: 0` lets nothing wait.
|
|
52
|
+
- Every call returns a promise with the result of its own run. An error rejects only that call and the queue goes on.
|
|
53
|
+
- A waiting call whose task aborts is skipped and resolves with `undefined`. When the task of a running call aborts, its slot is freed at once and its result is dropped, so pass `signal` to what `fn` does: a `fn` that ignores it can overlap with the next run.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Semantics
|
|
2
|
+
|
|
3
|
+
- **An aborted task delivers no result.** If the task aborts before a promise settles, the promise rejects with the abort reason, even if the value was just produced in the same tick. `latest`, `debounce`, `all`, `race`, `take` and the rest follow this rule.
|
|
4
|
+
- **`emit` is a plain, synchronous loop** (see "Errors in handlers"). A `ctx.emit` inside a handler is the same call: without `onError` an error of a nested handler is thrown into the handler that emitted; with `onError` it goes there and `ctx.emit` does not throw.
|
|
5
|
+
- **Rejections of async handlers** are not handled for you. Without `onError` they are unhandled rejections, which end a Node process by default. Use `onError` for async handlers.
|
|
6
|
+
- **`leading` releases its lock when the task aborts**, not only when `fn` settles. A `fn` that ignores the abort signal can then overlap with the next run, so pass `ctx.signal` to what `fn` does.
|
|
7
|
+
- **Effects start their function synchronously**, so an event emitted right after the call reaches a `take` inside it.
|
package/docs/stores.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Redux, Zustand, MobX
|
|
2
|
+
|
|
3
|
+
## Redux
|
|
4
|
+
|
|
5
|
+
`createEffectsMiddleware()` from `nanoeffects/redux` gives you a bus and the Redux middleware that feeds it: the effects of an app in one place, without sagas. Every action goes to the bus after the reducers have handled it, so a handler that reads the store sees the new state, and every task gets `ctx.store` (`dispatch` and `getState`).
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
type Events = { userRequested: string } & WithContext<{ store: ReduxStoreApi<RootState> }>;
|
|
9
|
+
|
|
10
|
+
const effects = createEffectsMiddleware<Events>();
|
|
11
|
+
|
|
12
|
+
const store = configureStore({
|
|
13
|
+
reducer,
|
|
14
|
+
middleware: (getDefault) => getDefault().concat(effects.middleware),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
effects.bus.on(userRequested, latest(async (id, { store, signal }) => {
|
|
18
|
+
store.dispatch(userLoaded(await fetchUser(id, { signal })));
|
|
19
|
+
}));
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
It looks after what is easy to get wrong:
|
|
23
|
+
|
|
24
|
+
- **A handler does not loop on its own action.** An action that a handler dispatches through `ctx.store` and that has the type the handler works on does not go back to the bus. The action is not marked or changed: the middleware remembers which task dispatched it. Other handlers of that type do not get it either. Longer cycles, `a` to `b` to `a`, are yours to avoid.
|
|
25
|
+
- **A failing handler does not break `dispatch`.** Errors go to `onError` (default `console.error`), as in Redux Toolkit listeners. Reducers still throw to the caller.
|
|
26
|
+
- **The store gets to the tasks by itself**, though the bus is made before the store: `ctx.store` appears when the middleware is added.
|
|
27
|
+
|
|
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
|
+
|
|
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
|
+
## Zustand, MobX and other stores
|
|
33
|
+
|
|
34
|
+
No adapter is needed: a store tells about its changes, and `bus.emit` is a call.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// Zustand
|
|
38
|
+
const stop = store.subscribe((state, previous) => {
|
|
39
|
+
if (state.user !== previous.user) bus.emit(userChanged, state.user);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// MobX
|
|
43
|
+
const stop = reaction(() => store.user, (user) => bus.emit(userChanged, user));
|
|
44
|
+
|
|
45
|
+
bus.on(userChanged, latest((user, { signal }) => sync(user, { signal })));
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Stop watching with what the store returned: `onAbort(ctx, stop)` inside a task, or `m.signal.addEventListener("abort", stop)` in a module.
|
|
49
|
+
|
|
50
|
+
The other way, give the store to the tasks, `createBus({ context: { store } })`, and change it from a handler: `setState` for Zustand, an action of the MobX store (it wraps `runInAction`). A handler that changes the value it was started by emits the event again, so watch the smallest part of the state. For a MobX `flow`, `onAbort(ctx, () => promise.cancel())` cancels it with the task.
|