nanoeffects 0.19.0 → 0.20.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/dist/{action-creator-HFA_LZOH.d.ts → action-creator-Bpa8BUS9.d.ts} +18 -7
- package/dist/actions.d.ts +3 -3
- package/dist/actions.js +1 -1
- package/dist/{context-types-BNcs43eY.d.ts → context-types-BKIH5a8s.d.ts} +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/dev.d.ts +2 -2
- package/dist/dev.js +1 -1
- package/dist/effects.d.ts +4 -8
- package/dist/index.d.ts +2 -2
- package/dist/match.d.ts +1 -1
- package/dist/{module-DTf73Wdq.d.ts → module-B0KVDNjY.d.ts} +14 -9
- package/dist/mount-each-D54UqAhZ.js +1 -0
- package/dist/notrace/{action-creator-CbFf398i.d.ts → action-creator-D2CotugT.d.ts} +16 -7
- package/dist/notrace/actions.d.ts +14 -9
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/effects.d.ts +4 -8
- package/dist/notrace/index.d.ts +2 -2
- package/dist/notrace/{plugin-C5vIjxhR.d.ts → plugin-C6KA4dX-.d.ts} +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/oop.d.ts +1 -1
- package/dist/{plugin-Sy8z9IId.d.ts → plugin-D9UTzGRG.d.ts} +2 -2
- package/dist/policies.d.ts +2 -2
- package/dist/react.d.ts +3 -4
- package/dist/redux.d.ts +1 -1
- package/dist/scope.d.ts +4 -4
- package/dist/signal.d.ts +1 -1
- package/dist/state-of-C19H6BoV.d.ts +10 -0
- package/dist/trace.d.ts +1 -1
- package/dist/{types-DaZT158m.d.ts → types-CbdJKC2z.d.ts} +2 -2
- package/dist/vue.d.ts +72 -0
- package/dist/vue.js +1 -0
- package/dist/{with-policy-C_mX7S54.d.ts → with-policy-BeH8wCkX.d.ts} +1 -1
- package/docs/README.md +2 -0
- package/docs/actions.md +31 -1
- package/docs/chat-example.md +241 -0
- package/docs/migrating-from-rxjs.md +58 -1
- package/docs/package.md +1 -0
- package/docs/plugins.md +3 -3
- package/docs/starter.md +14 -1
- package/docs/typescript.md +14 -0
- package/docs/vue.md +96 -0
- package/llms-full.txt +467 -7
- package/llms.txt +4 -2
- package/package.json +23 -3
- package/skills/nanoeffects/SKILL.md +12 -18
package/dist/vue.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { T as EventKey, h as Bus, r as FreeActionCreator, t as ActionCreator, z as EventMap } from "./action-creator-Bpa8BUS9.js";
|
|
2
|
+
import { t as OriginOptions } from "./origin-key-CxK0SkeG.js";
|
|
3
|
+
import { t as StateOf } from "./state-of-C19H6BoV.js";
|
|
4
|
+
import { ShallowRef, WatchSource, WatchStopHandle } from "vue";
|
|
5
|
+
//#region src/vue/plugin.d.ts
|
|
6
|
+
/** Tells whether two values are the same. */
|
|
7
|
+
type Equals<V> = (previous: V, next: V) => boolean;
|
|
8
|
+
/** The third argument of `watchEmitter`. */
|
|
9
|
+
type WatchEmitterOptions<V> = OriginOptions & {
|
|
10
|
+
/** Tells whether a new value is the same as the previous one, for a new object with the same fields. */
|
|
11
|
+
equals?: Equals<V>;
|
|
12
|
+
};
|
|
13
|
+
/** What `vuePlugin` adds to the bus. */
|
|
14
|
+
type VueApi<E extends EventMap> = {
|
|
15
|
+
/**
|
|
16
|
+
* Watches a ref or a getter and emits `event` with its new value when it changes. It
|
|
17
|
+
* does not emit when it is called, and it stops with the effect scope (a component) that
|
|
18
|
+
* called it. Like Vue's `watch`, it runs before the next render, so several changes in
|
|
19
|
+
* one tick emit once, with the last value. The method does not read `this`, so
|
|
20
|
+
* `export const { watchEmitter } = bus` gives a free function. It returns the function
|
|
21
|
+
* that stops it early.
|
|
22
|
+
*/
|
|
23
|
+
watchEmitter: {
|
|
24
|
+
<K extends EventKey<E>>(source: WatchSource<E[K]>, event: K, options?: WatchEmitterOptions<E[K]>): WatchStopHandle;
|
|
25
|
+
/** A creator without a payload type: the payload comes from the event map. */
|
|
26
|
+
<K extends EventKey<E>>(source: WatchSource<E[K]>, creator: FreeActionCreator<K>, options?: WatchEmitterOptions<E[K]>): WatchStopHandle;
|
|
27
|
+
<P, T extends string>(source: WatchSource<P>, creator: ActionCreator<P, T>, options?: WatchEmitterOptions<P>): WatchStopHandle;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* A ref that starts as `initial`, and that every emit of `event` sets to its payload
|
|
31
|
+
* while the effect scope (a component) that called it lives. A function payload is stored,
|
|
32
|
+
* not called. The bus keeps no values, so an event emitted before the call is not seen. It
|
|
33
|
+
* is state of one component, not a store. The method does not read `this`, so
|
|
34
|
+
* `export const { useEventRef } = bus` gives a free function.
|
|
35
|
+
*/
|
|
36
|
+
useEventRef: {
|
|
37
|
+
<K extends EventKey<E>, S>(event: K, initial: StateOf<E[K], S>): ShallowRef<StateOf<E[K], S>>;
|
|
38
|
+
/** A creator without a payload type: the payload comes from the event map. */
|
|
39
|
+
<K extends EventKey<E>, S>(creator: FreeActionCreator<K>, initial: StateOf<E[K], S>): ShallowRef<StateOf<E[K], S>>;
|
|
40
|
+
<P, T extends string>(creator: ActionCreator<P, T>, initial: P): ShallowRef<P>;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Adds `bus.watchEmitter(source, event)` and `bus.useEventRef(event, initial)`: the two
|
|
45
|
+
* jobs of a Vue component with the bus. `watchEmitter` tells the bus when a value of the
|
|
46
|
+
* component changes, and `useEventRef` keeps the last payload of an event in a ref. Pass
|
|
47
|
+
* `{ equals }` to `watchEmitter` for values that `Object.is` tells apart although they are
|
|
48
|
+
* the same, and `{ traceId }` and `{ traceGroupId }` to name the subscription and the flow
|
|
49
|
+
* in the trace of the bus. Needs `vue` 3.3 or newer. Returns the same bus.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* import { createBus } from "nanoeffects";
|
|
54
|
+
* import { createAction } from "nanoeffects/actions";
|
|
55
|
+
* import { vuePlugin } from "nanoeffects/vue";
|
|
56
|
+
* import { ref } from "vue";
|
|
57
|
+
*
|
|
58
|
+
* export const bus = createBus().use(vuePlugin());
|
|
59
|
+
* export const { watchEmitter, useEventRef } = bus; // free functions
|
|
60
|
+
*
|
|
61
|
+
* const nameChanged = createAction<string>("name/changed");
|
|
62
|
+
* const nameSaved = createAction<string[]>("name/saved");
|
|
63
|
+
*
|
|
64
|
+
* // inside setup():
|
|
65
|
+
* const name = ref("Adam");
|
|
66
|
+
* watchEmitter(name, nameChanged); // nameChanged("…") on every change of name, not on mount
|
|
67
|
+
* const saved = useEventRef(nameSaved, []); // Ref<string[]>, set by every nameSaved
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export declare const vuePlugin: () => <E extends EventMap>(bus: Bus<E>) => Bus<E> & VueApi<E>;
|
|
71
|
+
//#endregion
|
|
72
|
+
export type { VueApi };
|
package/dist/vue.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{t as e}from"./origin-key-BbWN3gmN.js";import{onScopeDispose as t,shallowRef as n,watch as r}from"vue";const i=()=>i=>{function a(t,n,a){return r(t,(t,r)=>{let o=r,s=t;if(a?.equals?.(o,s))return;let c=typeof n==`function`?n(s):{type:n,payload:t},l=Reflect.get(i,e);l?l(c,a??{}):i.emit(c)})}function o(e,r){let a=n(r),o=e;return t(i.on(o,e=>{a.value=e})),a}return Object.assign(i,{watchEmitter:a,useEventRef:o})};export{i as vuePlugin};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { H as PayloadOf, W as Task, z as EventMap } from "./action-creator-Bpa8BUS9.js";
|
|
2
2
|
//#region src/policies/with-policy.d.ts
|
|
3
3
|
/** What `withPolicy` returns: the task as a plain function that can be cancelled. */
|
|
4
4
|
type PolicyFunction<P, R> = {
|
package/docs/README.md
CHANGED
|
@@ -18,6 +18,7 @@ Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-che
|
|
|
18
18
|
| Domain logic off the UI | [Actions, commands, modules](actions.md) |
|
|
19
19
|
| Already on Redux / Zustand / MobX / Pinia | [Stores](stores.md) |
|
|
20
20
|
| A React form with async checks | [Build a sign-up form in React](react.md) |
|
|
21
|
+
| A Vue app | [Use nanoeffects in a Vue app](vue.md) |
|
|
21
22
|
| Old responses overwrite new ones | [Cancel stale requests](cancel-stale-requests.md) |
|
|
22
23
|
| Debounced input that must also cancel the old request | [Debounce and abort](debounce-and-abort.md) |
|
|
23
24
|
| A second click sends a second request | [Prevent a double submit](prevent-double-submit.md) |
|
|
@@ -32,6 +33,7 @@ Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-che
|
|
|
32
33
|
- [Effects and cancellation](effects.md) — `retry`, `timeout`, `poll`, `take`, `all`, …
|
|
33
34
|
- [Actions, commands, jobs and modules](actions.md)
|
|
34
35
|
- [Sign-up form in React](react.md) — one form: races, double submit, retry
|
|
36
|
+
- [nanoeffects in a Vue app](vue.md) — `watchEmitter` and `useEventRef`
|
|
35
37
|
- [Context fields](context.md) — store or client on every task
|
|
36
38
|
- [Use the bus from a class](oop.md) — `extendBus`, `@WithBus`, `@On`, and `dispose()` for the subscriptions of an instance
|
|
37
39
|
- [TypeScript in many files](typescript.md) — keep the types of the bus in commands and modules
|
package/docs/actions.md
CHANGED
|
@@ -6,6 +6,8 @@ If you only need `on` / `emit` or policies, stay in the [Guide](guide.md) until
|
|
|
6
6
|
|
|
7
7
|
Recipe without a UI framework: [`examples/modules.ts`](../examples/modules.ts).
|
|
8
8
|
|
|
9
|
+
A chat with four domains, jobs under policies and effects, and a stream tied to a module: [Build a chat with domains, modules and a small server](chat-example.md).
|
|
10
|
+
|
|
9
11
|
A whole project with two domains, a job and React as a thin layer: `npm create nanoeffects@latest my-app -- --template domains-ts` (see [Start a project from a template](starter.md#start-with-domains)).
|
|
10
12
|
|
|
11
13
|
## Return a value from `emit` with a command
|
|
@@ -252,11 +254,39 @@ app.commands.handle(getUser, fakeUser); // in a test
|
|
|
252
254
|
app.dispose(); // unmounts every module
|
|
253
255
|
```
|
|
254
256
|
|
|
257
|
+
`mount` and `unmount` take any number of modules. Use it to start the domains of an app yourself, at the moment you choose and in the order you give, when you do not give them to `modulePlugin`:
|
|
258
|
+
|
|
259
|
+
```js
|
|
260
|
+
import { createModule } from "nanoeffects/actions";
|
|
261
|
+
import { app } from "./bus";
|
|
262
|
+
|
|
263
|
+
const userModule = createModule("user", (m) => {
|
|
264
|
+
m.on("user/loaded", (user) => console.log(user));
|
|
265
|
+
});
|
|
266
|
+
const orderModule = createModule("order", (m) => {
|
|
267
|
+
m.on("order/placed", (order) => console.log(order));
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// mount — start both modules, left to right
|
|
271
|
+
const stop = app.mount(userModule, orderModule);
|
|
272
|
+
|
|
273
|
+
// the returned function stops them again, last mounted first (orderModule, then userModule)
|
|
274
|
+
stop();
|
|
275
|
+
|
|
276
|
+
// the same for modules that you already have in an array: spread it
|
|
277
|
+
const modules = [userModule, orderModule];
|
|
278
|
+
app.mount(...modules);
|
|
279
|
+
app.unmount(...modules); // also in reverse order
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
If the `setup` of one module throws, the modules of that call that were already mounted are unmounted again and the error is thrown, so a call mounts all of its modules or none. A module that is mounted already is skipped.
|
|
283
|
+
|
|
255
284
|
- **Unmounting undoes everything** the module did: subscriptions, bound commands, sub-modules, and background tasks (aborted).
|
|
256
285
|
- **A module is known by its name.** Mounting a module whose name is taken replaces the mounted one (see [Hot reload](hmr.md)).
|
|
257
286
|
- **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`.
|
|
258
287
|
- A disposed app cannot mount modules.
|
|
259
|
-
- **The given modules are mounted
|
|
288
|
+
- **The given modules are mounted at the end of the block that made the app**, or at the first use of the app or the bus if that comes first (the first `bus.emit`, or a call of `mount`, `unmount`, `isMounted`, `dispose`, or the read of `commands`). Nothing needs to use the app: `bus.use(modulePlugin([a, b]))` is enough to start them. The plugins that you use after `modulePlugin` in the same block still reach them, because the modules wait until that block has finished. A plugin used after an `await` can come too late: use every plugin before the first `await`. The `bus.on` calls made in between are held and made right after, so the listeners of a module stay ahead of them. See [using plugins in any order](plugins.md#use-them-in-any-order).
|
|
289
|
+
- **To mount by hand instead**, pass no modules: `modulePlugin()` starts nothing, and `app.mount(...)` starts the modules when you want.
|
|
260
290
|
- Module context = task context plus `handle`, `use`, and `spawn`:
|
|
261
291
|
- `handle` — give a command a handler (contract, or override).
|
|
262
292
|
- `use` — bring sub-modules and commands that carry a handler; the returned function undoes that call.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Build a chat with domains, modules and a small server
|
|
2
|
+
|
|
3
|
+
Use this page when your app has several features that react to each other and you want to see where each piece of logic goes. The example is a chat with rooms, a history that loads, messages that can fail to send, a search across all rooms, and new messages that arrive on their own. A small Fastify server in the example answers every request, and it misbehaves on a known schedule, so each problem on this page really happens.
|
|
4
|
+
|
|
5
|
+
It is a level D example. Start with [Actions, commands, jobs and modules](actions.md) for the API, or with the `domains-ts` template in [Start a project from a template](starter.md#start-with-domains) for a smaller project.
|
|
6
|
+
|
|
7
|
+
The code is in [`examples/chat`](../examples/chat). Run it on [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat), or on your machine:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cd examples/chat
|
|
11
|
+
npm install
|
|
12
|
+
npm run dev
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`npm run dev` starts the server and Vite together. Start it with `CHAOS=off npm run dev` to turn the failures off.
|
|
16
|
+
|
|
17
|
+
## Read how the server fails
|
|
18
|
+
|
|
19
|
+
The server keeps its data in memory. These rules are on purpose, and they are the same every run, so you can follow each section below by doing what the table says:
|
|
20
|
+
|
|
21
|
+
| What you do | What the server does | Section |
|
|
22
|
+
| ----------- | -------------------- | ------- |
|
|
23
|
+
| Select a room, then another before the first is shown | The room Random loads its history in 1.2 s, slower than the others. | [Load a history that must not race](#load-a-history-that-must-not-race) |
|
|
24
|
+
| Send messages | Every third send answers 503. | [Send a message that can fail](#send-a-message-that-can-fail) |
|
|
25
|
+
| Send `/fail hello` | Answers 400. | [Send a message that can fail](#send-a-message-that-can-fail) |
|
|
26
|
+
| Search a word that is in Random | Random answers after 2.5 s. | [Search where one room is slow](#search-where-one-room-is-slow) |
|
|
27
|
+
| Wait | A new message appears every 4 s. | [Poll for new messages](#poll-for-new-messages) |
|
|
28
|
+
|
|
29
|
+
Open the Network panel of the browser while you do this. A request that the page aborts shows as cancelled there, and the server prints `aborted: ...` for it.
|
|
30
|
+
|
|
31
|
+
## See how the files are split
|
|
32
|
+
|
|
33
|
+
Each domain is a folder. The components only emit events and read values; they never call the server.
|
|
34
|
+
|
|
35
|
+
| Path | Job |
|
|
36
|
+
| ---- | --- |
|
|
37
|
+
| `src/bus.ts` | Creates the bus. It imports no events and no domain. |
|
|
38
|
+
| `src/app.ts` | Mounts the four domains. |
|
|
39
|
+
| `src/api.ts` | The client of the server: one function per request. |
|
|
40
|
+
| `server/` | The Fastify server and the script that starts it with Vite. |
|
|
41
|
+
| `src/domains/rooms/` | The list of rooms and the selected room. |
|
|
42
|
+
| `src/domains/messages/` | The history of the open room and sending a message. |
|
|
43
|
+
| `src/domains/search/` | Searching every room and opening a result. |
|
|
44
|
+
| `src/domains/incoming/` | New messages, asked for at an interval. |
|
|
45
|
+
| `src/ui/` | The components. |
|
|
46
|
+
|
|
47
|
+
A domain folder has its events (`rooms.ts`, which imports no bus), its work (`rooms-handlers.ts`), the state it keeps (`rooms-store.ts`) and a module (`rooms-module.ts`) that wires them. A domain reacts to another domain by listening to its events, never by importing its handlers or its store.
|
|
48
|
+
|
|
49
|
+
## Load a history that must not race
|
|
50
|
+
|
|
51
|
+
**Problem.** The reader clicks a slow room and then a fast one. If the slow answer arrives last, it shows the wrong room, and a hand-written loading flag stays `true` when a request is aborted.
|
|
52
|
+
|
|
53
|
+
**When.** A load starts from a user choice that can change before it ends.
|
|
54
|
+
|
|
55
|
+
A job sends `pending` when its handler starts and exactly one of `success`, `fail` and `cancel` when it ends. `latest` around the handler aborts the older load when a newer one starts, so only the newer result is delivered, and the older one sends `cancel`:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
// messages.js
|
|
59
|
+
import { createJob } from "nanoeffects/actions";
|
|
60
|
+
|
|
61
|
+
// createJob — a command that also sends pending, success, fail and cancel
|
|
62
|
+
export const loadHistory = createJob("messages/loadHistory");
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
// messages-handlers.js
|
|
67
|
+
import { latest } from "nanoeffects/policies";
|
|
68
|
+
|
|
69
|
+
// latest — a load for a newer room aborts the older one, and its result is never delivered
|
|
70
|
+
export const fetchHistory = latest((roomId, { signal }) => api.loadHistory(roomId, { signal }));
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The module gives the job its work and keeps the state from the outcomes, so an aborted load clears the flag too (see [Show a loading state with a job](actions.md#show-a-loading-state-with-a-job)):
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
// messages-module.js
|
|
77
|
+
import { createModule } from "nanoeffects/actions";
|
|
78
|
+
import { loadHistory } from "./messages";
|
|
79
|
+
import { fetchHistory } from "./messages-handlers";
|
|
80
|
+
|
|
81
|
+
export const messagesModule = createModule("messages", (module) => {
|
|
82
|
+
// handle — this module does the work of loadHistory while it is mounted
|
|
83
|
+
module.handle(loadHistory, fetchHistory);
|
|
84
|
+
module.on(loadHistory.pending, () => console.log("show the spinner"));
|
|
85
|
+
module.on(loadHistory.cancel, () => console.log("a newer load replaced it"));
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Send a message that can fail
|
|
90
|
+
|
|
91
|
+
**Problem.** A message must appear at once, go out in the order it was sent, be tried again after a failure, and show as failed if it never arrives. The outcome `fail` carries only the error, not the call, so the page cannot tell which message failed.
|
|
92
|
+
|
|
93
|
+
**When.** An action has a visible result that can fail and that must keep its order.
|
|
94
|
+
|
|
95
|
+
`queue` keeps the order, `retry` tries a failed call again, and the handler throws an error that carries the id of the message:
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
import { retry } from "nanoeffects/effects";
|
|
99
|
+
import { queue } from "nanoeffects/policies";
|
|
100
|
+
|
|
101
|
+
class SendFailed extends Error {
|
|
102
|
+
constructor(localId, cause) {
|
|
103
|
+
super(cause.message);
|
|
104
|
+
this.localId = localId;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A 400 is an answer: asking again gives the same answer. A 503 or a lost connection may pass.
|
|
109
|
+
const isWorthRetrying = (error) => !(error.status < 500);
|
|
110
|
+
|
|
111
|
+
// retry — try a failed send again, up to 3 attempts, 200 ms apart, unless the answer was a "no"
|
|
112
|
+
const attempt = retry(
|
|
113
|
+
async (call, { signal }) => {
|
|
114
|
+
const message = await api.send(call.roomId, call.text, { signal });
|
|
115
|
+
return { localId: call.localId, message };
|
|
116
|
+
},
|
|
117
|
+
{ times: 3, delay: 200, shouldRetry: isWorthRetrying },
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// queue — messages go out one after another, in the order they were sent
|
|
121
|
+
export const deliver = queue(async (call, ctx) => {
|
|
122
|
+
try {
|
|
123
|
+
return await attempt(call, ctx);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw new SendFailed(call.localId, error);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The module adds the message on `pending`, marks it delivered on `success`, and marks the message named by the error as failed on `fail`. Send `/fail hello` to see a failure that is not tried again, and send a few messages to meet the 503 that is.
|
|
131
|
+
|
|
132
|
+
## Search where one room is slow
|
|
133
|
+
|
|
134
|
+
**Problem.** A search asks every room. If it waits for all of them and one room is slow or broken, the reader sees nothing. If it fails on the first error, the rooms that answered are lost.
|
|
135
|
+
|
|
136
|
+
**When.** One action fans out to several sources and a partial answer is better than none.
|
|
137
|
+
|
|
138
|
+
`timeout` limits each request, and `allSettled` waits for every request to end, whether it succeeded or failed. The handler keeps the hits and the names of the rooms that did not answer:
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
import { allSettled, timeout } from "nanoeffects/effects";
|
|
142
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
143
|
+
|
|
144
|
+
const rooms = [{ id: "general", name: "General" }, { id: "random", name: "Random" }];
|
|
145
|
+
|
|
146
|
+
const searchRoom = (room, query) => ({ signal }) => api.searchRoom(room.id, query, { signal });
|
|
147
|
+
|
|
148
|
+
// timeout — a room that does not answer in 1.5 s fails with a timeout error
|
|
149
|
+
const askRoom = (room, query) => {
|
|
150
|
+
const request = searchRoom(room, query);
|
|
151
|
+
return (parent) => timeout(parent, 1500, request);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// debounce — wait for quiet; latest — a newer query aborts the older search
|
|
155
|
+
export const runSearch = debounce(
|
|
156
|
+
300,
|
|
157
|
+
latest(async (query, ctx) => {
|
|
158
|
+
// allSettled — ask every room at once and wait for all of them, however each one ends
|
|
159
|
+
const outcomes = await allSettled(ctx, rooms.map((room) => askRoom(room, query)));
|
|
160
|
+
return outcomes.map((outcome, index) => ({ room: rooms[index], outcome }));
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`all` would fail the whole search on the first slow room, which is the wrong behavior here. See [Effects](effects.md) for the others.
|
|
166
|
+
|
|
167
|
+
## Wait for a room before marking a result
|
|
168
|
+
|
|
169
|
+
**Problem.** The reader opens a search result from another room. The message cannot be marked until that room's history has loaded, and the handler must not guess how long that takes.
|
|
170
|
+
|
|
171
|
+
**When.** A handler starts work in another domain and needs to continue after its outcome.
|
|
172
|
+
|
|
173
|
+
`take` waits for the next event of a name. Start it before the emit that causes the event, so nothing is missed:
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
import { take } from "nanoeffects/effects";
|
|
177
|
+
|
|
178
|
+
// the room that is open, kept from the rooms/selected event
|
|
179
|
+
let selectedRoom = "general";
|
|
180
|
+
|
|
181
|
+
export const openResult = async (hit, ctx) => {
|
|
182
|
+
if (hit.roomId !== selectedRoom) {
|
|
183
|
+
// take — wait for the next event of that name; started before the emit so nothing is missed
|
|
184
|
+
const loaded = take(ctx, "messages/loadHistory/success");
|
|
185
|
+
ctx.emit("rooms/selected", hit.roomId);
|
|
186
|
+
await loaded;
|
|
187
|
+
}
|
|
188
|
+
ctx.emit("messages/marked", hit.id);
|
|
189
|
+
};
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Poll for new messages
|
|
193
|
+
|
|
194
|
+
**Problem.** New messages come from the server, and the page has to ask for them at an interval. The polling must stop when the feature is switched off, and one failed request must not stop it.
|
|
195
|
+
|
|
196
|
+
**When.** A feature has background work that lives as long as the feature does.
|
|
197
|
+
|
|
198
|
+
`poll` calls a function now and then at an interval, and ends when its context is aborted. A module starts the task with `spawn`, and unmounting the module aborts it:
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
import { createModule } from "nanoeffects/actions";
|
|
202
|
+
import { poll } from "nanoeffects/effects";
|
|
203
|
+
|
|
204
|
+
const watchUpdates = async (_, ctx) => {
|
|
205
|
+
// the number of the last update seen; the first answer only tells where the server is
|
|
206
|
+
let since;
|
|
207
|
+
|
|
208
|
+
const check = async () => {
|
|
209
|
+
try {
|
|
210
|
+
const updates = await api.loadUpdates(since, { signal: ctx.signal });
|
|
211
|
+
since = updates.seq;
|
|
212
|
+
for (const message of updates.messages) {
|
|
213
|
+
// ctx.emit — after an abort it delivers nothing
|
|
214
|
+
ctx.emit("incoming/received", message);
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
// one failed request must not end the polling: the next tick asks again
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// poll — call `check` now and then every 3 s; an abort of ctx (an unmounted module) ends it
|
|
222
|
+
await poll(ctx, check, 3000, { leading: true });
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export const incomingModule = createModule("incoming", (module) => {
|
|
226
|
+
// spawn — a background task that lives as long as this module
|
|
227
|
+
module.spawn(watchUpdates);
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
In the example a button calls `app.unmount(incomingModule)` and `app.mount(incomingModule)`, so you can watch the polling stop (the Network panel shows no more `updates` requests) and start.
|
|
232
|
+
|
|
233
|
+
## Know where each value lives
|
|
234
|
+
|
|
235
|
+
Each domain keeps its state in a [Zustand](https://zustand.docs.pmnd.rs/) store that the module writes and a component reads with `useStore`. Each store lists its mutations, such as `startLoading(roomId)` and `markFailed(localId)`, and a module calls them with the data of an event: `module.on(loadHistory.pending, startLoading)`. A mutation that sets plain values gives `set` an object, as in `set({ searching: true })`. The store of the messages uses the `immer` middleware, so a mutation that changes a list can change it in place, as in `state.messages.push(message)`, and the store still gives React a new object. Nothing else writes the state. The list of rooms is the exception: it is read with one `useEventState` in a provider at the root. `useEventState` is not a store: an event that came before the provider mounted is lost, and a second component gets its own copy. See [Show the last value of an event as state](react.md#show-the-last-value-of-an-event-as-state).
|
|
236
|
+
|
|
237
|
+
## Where to go next
|
|
238
|
+
|
|
239
|
+
- The API of commands, jobs and modules: [Actions, commands, jobs and modules](actions.md).
|
|
240
|
+
- Policies such as `latest` and `queue`: [Policies](policies.md). Effects such as `allSettled`, `take` and `poll`: [Effects](effects.md).
|
|
241
|
+
- A smaller project with two domains: [Start with domains](starter.md#start-with-domains).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Use nanoeffects next to RxJS
|
|
2
2
|
|
|
3
|
-
Read this page if your project uses RxJS and you wonder what nanoeffects takes over. It does not replace RxJS. The two split the work: RxJS shapes streams of values, nanoeffects runs the side effects that events start. It maps the operators, shows
|
|
3
|
+
Read this page if your project uses RxJS and you wonder what nanoeffects takes over. It does not replace RxJS. The two split the work: RxJS shapes streams of values, nanoeffects runs the side effects that events start. It maps the operators, shows a search box and a polling loop both ways, and lists what stays in RxJS.
|
|
4
4
|
|
|
5
5
|
## Split the work
|
|
6
6
|
|
|
@@ -82,6 +82,63 @@ input.addEventListener("input", (event) => bus.emit("typed", event.target.value)
|
|
|
82
82
|
|
|
83
83
|
The skip of repeated values is not here. Add `if (query === lastQuery) return;` to the handler, or keep that one step in RxJS (next section).
|
|
84
84
|
|
|
85
|
+
## Rewrite a polling loop
|
|
86
|
+
|
|
87
|
+
Check a job status every 5 seconds. Retry a failed check, and stop when the user leaves the screen.
|
|
88
|
+
|
|
89
|
+
The same job in RxJS: `timer` ticks, `exhaustMap` skips a tick while a check runs, `retry` repeats a failed check, `takeUntil` stops it.
|
|
90
|
+
|
|
91
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
92
|
+
```js
|
|
93
|
+
// RxJS
|
|
94
|
+
import { timer } from "rxjs";
|
|
95
|
+
import { exhaustMap, retry, takeUntil } from "rxjs/operators";
|
|
96
|
+
|
|
97
|
+
timer(5000, 5000)
|
|
98
|
+
.pipe(
|
|
99
|
+
exhaustMap(() => api.statusStream().pipe(retry({ count: 3, delay: 500 }))),
|
|
100
|
+
takeUntil(leave$),
|
|
101
|
+
)
|
|
102
|
+
.subscribe((status) => render(status));
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The same job in nanoeffects. `poll` ticks, `retry` repeats one check, and `race` ends the loop on the first `"leave"` event:
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
import { createBus } from "nanoeffects";
|
|
109
|
+
import { poll, race, retry, take } from "nanoeffects/effects";
|
|
110
|
+
|
|
111
|
+
const bus = createBus(); // the bus: events and handlers
|
|
112
|
+
|
|
113
|
+
bus.on("watch", async (_, ctx) => {
|
|
114
|
+
// one check, up to 3 attempts, 500 ms between them
|
|
115
|
+
const checkStatus = async () => {
|
|
116
|
+
const status = await retry(ctx, () => api.status({ signal: ctx.signal }), {
|
|
117
|
+
times: 3,
|
|
118
|
+
delay: 500,
|
|
119
|
+
});
|
|
120
|
+
ctx.emit("status", status);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
await race(ctx, {
|
|
124
|
+
// poll: a check every 5 s; waitForFn: the next pause starts after the check ends
|
|
125
|
+
polling: ({ signal }) => poll({ signal }, checkStatus, 5000, { waitForFn: true }),
|
|
126
|
+
// take: resolves at the next "leave", and race stops the polling
|
|
127
|
+
leave: ({ signal }) => take(ctx, "leave", { signal }),
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
bus.on("status", (status) => render(status));
|
|
132
|
+
|
|
133
|
+
bus.emit("watch"); // start
|
|
134
|
+
bus.emit("leave"); // stop
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Two things differ from the pipeline:
|
|
138
|
+
|
|
139
|
+
- `waitForFn: true` plays the role of `exhaustMap`: checks never overlap. Without it `poll` keeps a steady rate, and a slow check can overlap the next one.
|
|
140
|
+
- If all 3 attempts fail, the error stops `poll`, as an error ends an Observable. To keep polling after a failed check, catch it inside `checkStatus` and emit an `"error"` event.
|
|
141
|
+
|
|
85
142
|
## Use both in one app
|
|
86
143
|
|
|
87
144
|
Keep RxJS where a stream is how you think, and let events cross to the bus in either direction.
|
package/docs/package.md
CHANGED
|
@@ -16,6 +16,7 @@ Imports are split so you only load what you use. `createBus` stays small; the re
|
|
|
16
16
|
| `nanoeffects/redux` | `createEffectsMiddleware` |
|
|
17
17
|
| `nanoeffects/observable` | `createObserve`, `Subject` |
|
|
18
18
|
| `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
|
|
19
|
+
| `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) |
|
|
19
20
|
| `nanoeffects/dev` | `devPlugin` (no-op in production) |
|
|
20
21
|
| `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) |
|
|
21
22
|
|
package/docs/plugins.md
CHANGED
|
@@ -82,7 +82,7 @@ import { modulePlugin } from "nanoeffects/actions";
|
|
|
82
82
|
|
|
83
83
|
const bus = createBus();
|
|
84
84
|
|
|
85
|
-
// modulePlugin — mounts userModule
|
|
85
|
+
// modulePlugin — mounts userModule at the end of this block, or at the first use if that is earlier, and returns the app
|
|
86
86
|
const app = bus.use(modulePlugin([userModule]));
|
|
87
87
|
|
|
88
88
|
app.unmount(userModule); // its listeners, bound commands and tasks are gone
|
|
@@ -261,7 +261,7 @@ export const bus = createBus()
|
|
|
261
261
|
// commandsPlugin — the registry of command handlers; keep it to bind handlers later
|
|
262
262
|
export const commands = bus.use(commandsPlugin());
|
|
263
263
|
|
|
264
|
-
// modulePlugin — mounts userModule
|
|
264
|
+
// modulePlugin — mounts userModule at the end of this block; keep app to mount and unmount later
|
|
265
265
|
export const app = bus.use(modulePlugin([userModule]));
|
|
266
266
|
|
|
267
267
|
// devPlugin — reports leaks and modules while you develop; keep disconnect to remove it
|
|
@@ -274,7 +274,7 @@ The plugins give the same result in any order. Use them as they read best, and p
|
|
|
274
274
|
|
|
275
275
|
Two things happen because modules are mounted late:
|
|
276
276
|
|
|
277
|
-
- **Modules start
|
|
277
|
+
- **Modules start at the end of the block.** `modulePlugin([userModule])` mounts `userModule` when the synchronous block that made it has finished, or earlier if the app or the bus is used first: the first `bus.emit`, or the first call of `app.mount`, `app.unmount`, `app.isMounted`, `app.dispose` or the read of `app.commands`. By then every plugin used in the same block is on the bus, so a module and the tasks it spawns have the fields, effects and scopes of the plugins you used after `modulePlugin`, and `m.on(fn, handler)` works with `matchPlugin`. A plugin used after an `await` can come too late: use every plugin before the first `await`. With no module, `modulePlugin()` starts nothing until the first use.
|
|
278
278
|
- **Listeners keep their place.** A `bus.on` after `bus.use(modulePlugin(...))` is held until the modules are mounted, then made, in the order you wrote them. The listeners of a module therefore stay ahead of the ones registered after the plugin, and behind the ones registered before it. The function `bus.on` returns works before and after.
|
|
279
279
|
|
|
280
280
|
Other plugins do not need an order for another reason:
|
package/docs/starter.md
CHANGED
|
@@ -47,6 +47,7 @@ Pass `--template` to skip the question. With npm, put `--` before the option.
|
|
|
47
47
|
| `vanilla-ts` | The `vanilla` app in TypeScript | You want the compiler to check events and handlers |
|
|
48
48
|
| `react-ts` | The `react` app in TypeScript | You build a React app in TypeScript |
|
|
49
49
|
| `domains-ts` | Commands, a job and modules, with the logic split into domain folders and React as a thin layer | You build an app with more than one feature and want the logic off the UI |
|
|
50
|
+
| `vue-ts` | The `react-ts` app in Vue 3, with `watchEmitter` and `useEventRef` from `nanoeffects/vue` | You build a Vue 3 app |
|
|
50
51
|
|
|
51
52
|
```bash
|
|
52
53
|
npm create nanoeffects@latest my-app -- --template react
|
|
@@ -60,6 +61,16 @@ The TypeScript templates are the same apps with the same files, written in `.ts`
|
|
|
60
61
|
npm create nanoeffects@latest my-app -- --template react-ts
|
|
61
62
|
```
|
|
62
63
|
|
|
64
|
+
### Start with Vue
|
|
65
|
+
|
|
66
|
+
`vue-ts` is the search app of `react-ts` in Vue 3 with `<script setup lang="ts">`. Its bus uses `vuePlugin` from `nanoeffects/vue`: `watchEmitter` sends the query to the bus when it changes, and `useEventRef` keeps the result of the search in a `ref`. Like `useEventState` in React, `useEventRef` is [not a store](vue.md#it-is-not-a-store): a value that several components share belongs in a store such as Pinia.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm create nanoeffects@latest my-app -- --template vue-ts
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`npm run build` type-checks the `.vue` files with `vue-tsc`, which needs TypeScript 5: the template depends on it.
|
|
73
|
+
|
|
63
74
|
### Start with domains
|
|
64
75
|
|
|
65
76
|
`domains-ts` is not the search box of the other templates grown in TypeScript: it shows where logic goes when an app has several features. Each domain is a folder under `src/domains/`. It has the events of the domain, the work that handles them, and a module that mounts the work. The React components only emit events and read values.
|
|
@@ -76,11 +87,13 @@ npm create nanoeffects@latest my-app -- --template domains-ts
|
|
|
76
87
|
| `src/domains/checkout/` | Placing an order: `checkout.ts` (a job), `checkout-handlers.ts` (`leading` and `retry`), `checkout-store.ts` (state written from the job's outcomes), `checkout-module.ts`. |
|
|
77
88
|
| `src/ui/` | The components. None of them calls the fake server in `src/api.ts`. |
|
|
78
89
|
|
|
90
|
+
For a bigger example with the same layout, a chat with four domains and mocks, see [Build a chat with domains, modules and a small server](chat-example.md).
|
|
91
|
+
|
|
79
92
|
The commands, jobs and modules themselves are explained in [Actions, commands, jobs and modules](actions.md). The template keeps found products in a Context provider with one `useEventState`, and the order state in a store of its own: `useEventState` is [not a store](react.md#show-the-last-value-of-an-event-as-state).
|
|
80
93
|
|
|
81
94
|
## Read the files
|
|
82
95
|
|
|
83
|
-
Every template except `domains-ts` splits the code the way [the guide](guide.md) does: one job per file. In the TypeScript templates the files end in `.ts` (and `.tsx` for components).
|
|
96
|
+
Every template except `domains-ts` splits the code the way [the guide](guide.md) does: one job per file. In the TypeScript templates the files end in `.ts` (and `.tsx` for React components, `.vue` for Vue components).
|
|
84
97
|
|
|
85
98
|
| File | Job |
|
|
86
99
|
| ---- | --- |
|
package/docs/typescript.md
CHANGED
|
@@ -129,6 +129,20 @@ const user = bus.emit(getUser("1")); // Promise<User>
|
|
|
129
129
|
|
|
130
130
|
`createModule` of the app only creates the module: it does not mount it, and it keeps no link to the bus. That is why the app in `bus.ts` starts without modules. `createModule` from `nanoeffects/actions` stays for a module made without a bus: there you write the event map yourself, `createModule<AppEvents>("user", …)`.
|
|
131
131
|
|
|
132
|
+
## Type a function that takes the signal
|
|
133
|
+
|
|
134
|
+
A function that makes a request should take the `signal` of the task, so aborting the task cancels the request. `WithSignal` is the type for that parameter: an object with a `signal`. It is checker-only, like `WithContext`: it adds nothing to your bundle.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import type { WithSignal } from "nanoeffects";
|
|
138
|
+
|
|
139
|
+
// WithSignal — { signal: AbortSignal }; a task context fits it, and so does { signal } on its own
|
|
140
|
+
const search = (query: string, { signal }: WithSignal) =>
|
|
141
|
+
fetch(`/search?q=${encodeURIComponent(query)}`, { signal });
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Pass `ctx` (or `{ signal }`) when you call it. The effects of `nanoeffects/effects` (`retry`, `timeout`, `all`) take an optional signal instead, because outside a task you may pass `{}`; that is a different type, and you do not write it.
|
|
145
|
+
|
|
132
146
|
## Keep the contract apart from the code
|
|
133
147
|
|
|
134
148
|
When the file with the command must not import the code that implements it, make the command without a handler. Here you write the payload and the result once, as generics, and the handler goes to a module, where it needs no annotation:
|