nanoeffects 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -23
- package/dist/{action-creator-Bpa8BUS9.d.ts → action-creator-D_c223C7.d.ts} +51 -23
- package/dist/actions.d.ts +54 -21
- package/dist/actions.js +1 -1
- package/dist/{bus-hHp-T7JV.js → bus-DQDHUN5e.js} +1 -1
- package/dist/{context-types-BKIH5a8s.d.ts → context-types-nGtzhjxD.d.ts} +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/dev.d.ts +2 -2
- package/dist/effects.d.ts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/match.d.ts +1 -1
- package/dist/{module-B5V47MZ0.d.ts → module-WX0Gngnt.d.ts} +40 -57
- package/dist/notrace/{action-creator-D2CotugT.d.ts → action-creator-Bux7Zzsl.d.ts} +51 -23
- package/dist/notrace/actions.d.ts +114 -98
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/{bus-BdyvumpX.js → bus-CGi2lrd5.js} +1 -1
- package/dist/notrace/effects.d.ts +2 -2
- package/dist/notrace/index.d.ts +2 -2
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/{plugin-ks-oNq5n.d.ts → plugin-CATXOxtj.d.ts} +10 -9
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/observable.d.ts +6 -6
- package/dist/oop.d.ts +2 -2
- package/dist/oop.js +1 -1
- package/dist/{plugin-B6UQyoVd.d.ts → plugin-vvkmIIz2.d.ts} +11 -10
- package/dist/policies.d.ts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/redux.d.ts +1 -1
- package/dist/scope.d.ts +2 -2
- package/dist/signal.d.ts +1 -1
- package/dist/signal.js +1 -1
- package/dist/svelte.d.ts +1 -1
- package/dist/trace.d.ts +1 -1
- package/dist/{types-DWjyeii7.d.ts → types-Bf-LX_tE.d.ts} +2 -2
- package/dist/vue.d.ts +1 -1
- package/dist/{with-policy-BeH8wCkX.d.ts → with-policy-CPjx0sLm.d.ts} +1 -1
- package/docs/README.md +5 -4
- package/docs/actions.md +93 -52
- package/docs/chat-example.md +6 -6
- package/docs/context.md +1 -1
- package/docs/effects.md +2 -2
- package/docs/errors.md +1 -1
- package/docs/guide.md +25 -25
- package/docs/hmr.md +1 -1
- package/docs/motivation.md +10 -4
- package/docs/oop.md +2 -0
- package/docs/package.md +1 -1
- package/docs/plugins.md +23 -23
- package/docs/query-libraries.md +14 -14
- package/docs/scope.md +22 -22
- package/docs/server.md +12 -12
- package/docs/stability.md +90 -0
- package/docs/starter.md +1 -1
- package/docs/tracing.md +2 -2
- package/docs/typescript.md +16 -16
- package/llms-full.txt +326 -183
- package/llms.txt +3 -2
- package/package.json +4 -3
package/docs/motivation.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# Why nanoeffects exists
|
|
2
2
|
|
|
3
|
-
Read this page
|
|
3
|
+
Read this page to see why the library exists and when to pick it. The main reason is to take business logic and side effects out of React, Vue or Svelte components and write them as plain `async` functions, with cancellation, retries and a context for dependencies. The second part of the page is for SDKs, core libraries and micro-frontend shells, where a shared dependency is a shared risk. It does not teach the API: start with the [Guide](guide.md).
|
|
4
4
|
|
|
5
|
-
## The
|
|
5
|
+
## The main reason: logic outside the UI
|
|
6
|
+
|
|
7
|
+
Components that hold requests, timers and races are hard to test and easy to break. The usual cure is services and pure functions outside the UI, and then every service needs the same things: to cancel work that is no longer wanted, to retry, to share dependencies, and to show what ran and why.
|
|
8
|
+
|
|
9
|
+
The common tools ask a lot: redux-saga writes this logic as generators, and RxJS as streams and operators. nanoeffects keeps ordinary `async` functions and adds one idea, the `AbortSignal`. The event bus is its heart but not its value: any emitter can send events (`nanoevents` is about 100 bytes, and there are [dozens more](https://github.com/gajus/sister#similar-libraries)). What nanoeffects adds is cancellable tasks, policies and effects, a context that works as a small container for dependencies, actions and modules to group a feature, and [tracing](tracing.md) with an adapter for Redux DevTools. It also fits what a project already has: recipes and small adapters for [stores](stores.md) (Redux Toolkit, Zustand, MobX, Pinia), an [RxJS](migrating-from-rxjs.md) bridge, and helpers for [React](react.md), [Vue](vue.md) and [Svelte](svelte.md), so nothing has to be rewritten to start.
|
|
10
|
+
|
|
11
|
+
## When the constraint is a shared library
|
|
6
12
|
|
|
7
13
|
A shared library (an SDK, a core package, a platform shell) is loaded by many apps. Whatever it depends on, the apps depend on too. State and event libraries are the worst case, because they keep **internal state and identity** (a store, a context, a class). Two copies or two versions of them do not merge: they silently stop seeing each other.
|
|
8
14
|
|
|
@@ -31,9 +37,9 @@ A library that is **not** a shared singleton cannot break this way. nanoeffects
|
|
|
31
37
|
## What the library does instead
|
|
32
38
|
|
|
33
39
|
- **No dependencies, no global state.** It is safe to ship inside an SDK next to whatever the host app runs. It does not need a `singleton` entry in the Module Federation config.
|
|
34
|
-
- **Small enough for a strict budget.** SDKs often get a few kilobytes for all third-party code. `createBus` is about 1.4 kB gzip (1.3 kB brotli); policies, effects and
|
|
40
|
+
- **Small enough for a strict budget.** SDKs often get a few kilobytes for all third-party code. `createBus` is about 1.4 kB gzip (1.3 kB brotli); policies, effects and actions are separate entries you import only when you need them. See [Package entries](package.md).
|
|
35
41
|
- **Simple first.** `on` and `emit` are the whole model. Cancellation, retry and the rest are plain functions on top. The [Guide](guide.md) and [Coming from redux-saga](migrating-from-saga.md) map the familiar names.
|
|
36
|
-
- **`emit` can return an answer.**
|
|
42
|
+
- **`emit` can return an answer.** An [action with a handler](actions.md) returns a result, so one module can ask another and wait for the reply. Events alone cannot do that, and it was the main thing missing from the Redux-based options.
|
|
37
43
|
- **Easy to debug.** Event-driven code is hard to follow: you see an effect and not the event that caused it. Every task knows its parent, so [tracing](tracing.md) shows the chain of events, with loggers for the console, the Performance panel and Redux DevTools.
|
|
38
44
|
|
|
39
45
|
## When this is not the right tool
|
package/docs/oop.md
CHANGED
|
@@ -261,6 +261,8 @@ export const Cache = extendBus(bus, CacheBase);
|
|
|
261
261
|
|
|
262
262
|
## Subscribe a method with `@On` and `@Once`
|
|
263
263
|
|
|
264
|
+
`On` here is a decorator. The main entry also exports a type named `On`, the type of `bus.on`; they are different things in different imports, and a file that needs both can rename one: `import { On as OnDecorator } from "nanoeffects/oop"`.
|
|
265
|
+
|
|
264
266
|
A decorator turns a method into a handler of an event for each instance, with `this` bound. You write no `this.on` in the constructor, and `dispose()` ends the subscription too.
|
|
265
267
|
|
|
266
268
|
The bus and the events are the same as above, in TypeScript:
|
package/docs/package.md
CHANGED
|
@@ -7,7 +7,7 @@ Imports are split so you only load what you use. `createBus` stays small; the re
|
|
|
7
7
|
| `nanoeffects` | `createBus`, the types |
|
|
8
8
|
| `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
|
|
9
9
|
| `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
|
|
10
|
-
| `nanoeffects/actions` | `createAction`, `
|
|
10
|
+
| `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` |
|
|
11
11
|
| `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events |
|
|
12
12
|
| `nanoeffects/context` | `contextPlugin`: your own fields (a store, a client) on `ctx` of every task, typed from the value |
|
|
13
13
|
| `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal` |
|
package/docs/plugins.md
CHANGED
|
@@ -10,14 +10,14 @@ In TypeScript keep the result of `contextPlugin` and `effectsPlugin`: the type o
|
|
|
10
10
|
|
|
11
11
|
## Main plugins
|
|
12
12
|
|
|
13
|
-
These four cover what most apps need: shared dependencies, effects,
|
|
13
|
+
These four cover what most apps need: shared dependencies, effects, handlers and modules. Start here.
|
|
14
14
|
|
|
15
15
|
| Plugin | What it gives you | Import | `bus.use` returns |
|
|
16
16
|
| ------ | ----------------- | ------ | ----------------- |
|
|
17
17
|
| [`contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin) | Your own fields (a store, a client, a logger) on `ctx` of every handler. | `nanoeffects` | The same bus, typed with the fields |
|
|
18
18
|
| [`effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin) | `race`, `all`, `delay` and your own effects on `ctx`, called without passing `ctx`. | `nanoeffects/effects` | The same bus, typed with the effects |
|
|
19
|
-
| [`
|
|
20
|
-
| [`modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin) | A module: the listeners, handlers and tasks of one domain, mounted and unmounted with one call. | `nanoeffects/actions` | The app `{ mount, unmount, isMounted, dispose,
|
|
19
|
+
| [`actionsPlugin`](#let-a-test-replace-a-handler-commandsplugin) | The handlers of actions in a registry, so a test can bind or replace one. | `nanoeffects/actions` | The registry `{ run, handle }` |
|
|
20
|
+
| [`modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin) | A module: the listeners, handlers and tasks of one domain, mounted and unmounted with one call. | `nanoeffects/actions` | The app `{ mount, unmount, isMounted, dispose, actions, createModule }` |
|
|
21
21
|
|
|
22
22
|
### Put a store or a client on every handler: `contextPlugin`
|
|
23
23
|
|
|
@@ -54,27 +54,27 @@ bus.on("load", async (id, { race, delay }) => {
|
|
|
54
54
|
});
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
###
|
|
57
|
+
### Bind or replace a handler: `actionsPlugin`
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
Handlers of actions can live in a registry that belongs to the bus. An action can have no handler, and a test or another layer binds one. More in [Actions](actions.md#bind-a-handler-later).
|
|
60
60
|
|
|
61
61
|
```js
|
|
62
62
|
import { createBus } from "nanoeffects";
|
|
63
|
-
import {
|
|
63
|
+
import { actionsPlugin, createAction } from "nanoeffects/actions";
|
|
64
64
|
|
|
65
|
-
const getUser =
|
|
65
|
+
const getUser = createAction("user/get"); // no handler yet: an event
|
|
66
66
|
const bus = createBus();
|
|
67
67
|
|
|
68
|
-
//
|
|
69
|
-
const
|
|
68
|
+
// actionsPlugin — the registry of handlers of this bus
|
|
69
|
+
const actions = bus.use(actionsPlugin());
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
actions.handle(getUser, async (id) => ({ id })); // the latest binding wins
|
|
72
72
|
const user = await bus.emit(getUser("1")); // { id: "1" }
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
### Mount a domain as one unit: `modulePlugin`
|
|
76
76
|
|
|
77
|
-
A module groups the listeners, the bound
|
|
77
|
+
A module groups the listeners, the bound handlers and the background tasks of one domain, and ends all of them when it is unmounted. It also makes [hot reload](hmr.md) work. More in [Modules](actions.md#mount-domains-with-modules).
|
|
78
78
|
|
|
79
79
|
```js
|
|
80
80
|
import { createBus } from "nanoeffects";
|
|
@@ -94,7 +94,7 @@ Add one of these when you meet its need. None of them is required.
|
|
|
94
94
|
|
|
95
95
|
| Plugin | What it gives you | Import | `bus.use` returns |
|
|
96
96
|
| ------ | ----------------- | ------ | ----------------- |
|
|
97
|
-
| [`scopePlugin`](#give-one-
|
|
97
|
+
| [`scopePlugin`](#give-one-action-its-own-fields-scopeplugin) | Its own fields for one action, job or module, and a way to replace them in one `emit`. | `nanoeffects/scope` | The same bus |
|
|
98
98
|
| [`signalPlugin`](#tie-an-emit-to-an-outside-signal-signalplugin) | `emit(call, { signal })` that aborts its tasks when an outside signal aborts. | `nanoeffects/signal` | The same bus, typed with the new `emit` |
|
|
99
99
|
| [`matchPlugin`](#listen-to-a-group-of-events-matchplugin) | `on` that listens to a group of events which a function picks. | `nanoeffects/match` | The same bus, typed with the new overloads |
|
|
100
100
|
| [`emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin) | A callback that emits an action, to give to a state manager. | `nanoeffects/actions` | The same bus, with `emitter` |
|
|
@@ -102,20 +102,20 @@ Add one of these when you meet its need. None of them is required.
|
|
|
102
102
|
| [`reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin) | A React hook that emits when a value of a component changes. | `nanoeffects/react` | The same bus, with `useEmitter` |
|
|
103
103
|
| [`devPlugin`](#find-leaks-while-you-develop-devplugin) | Warnings about listeners that pile up, and messages about modules, while you develop. | `nanoeffects/dev` | A function that disconnects it |
|
|
104
104
|
|
|
105
|
-
### Give one
|
|
105
|
+
### Give one action its own fields: `scopePlugin`
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
An action with a handler, an action, a job or a module can carry its own fields and effects, and one `emit` can replace them. A test gives a fake to one call. More in [Scopes](scope.md).
|
|
108
108
|
|
|
109
109
|
```js
|
|
110
110
|
import { createBus } from "nanoeffects";
|
|
111
|
-
import {
|
|
111
|
+
import { createAction } from "nanoeffects/actions";
|
|
112
112
|
import { scopePlugin } from "nanoeffects/scope";
|
|
113
113
|
|
|
114
114
|
// scopePlugin — makes the scope of a command work on this bus
|
|
115
115
|
const bus = createBus().use(scopePlugin());
|
|
116
116
|
|
|
117
117
|
// the last argument: fields of this command only
|
|
118
|
-
const getUser =
|
|
118
|
+
const getUser = createAction("user/get", (id, { api }) => api.getUser(id), {
|
|
119
119
|
api,
|
|
120
120
|
});
|
|
121
121
|
|
|
@@ -129,13 +129,13 @@ A library such as React Query owns the request and gives you an `AbortSignal`. `
|
|
|
129
129
|
|
|
130
130
|
```js
|
|
131
131
|
import { createBus } from "nanoeffects";
|
|
132
|
-
import {
|
|
132
|
+
import { createAction } from "nanoeffects/actions";
|
|
133
133
|
import { signalPlugin } from "nanoeffects/signal";
|
|
134
134
|
|
|
135
135
|
// signalPlugin — emit takes { signal } for a command, a job or an action
|
|
136
136
|
const bus = createBus().use(signalPlugin());
|
|
137
137
|
|
|
138
|
-
const getUser =
|
|
138
|
+
const getUser = createAction("user/get", (id, { signal }) => api.getUser(id, { signal }));
|
|
139
139
|
|
|
140
140
|
const controller = new AbortController();
|
|
141
141
|
bus.emit(getUser("1"), { signal: controller.signal }); // runs the command
|
|
@@ -242,7 +242,7 @@ One bus with every plugin that does not need React, in a file of its own. The ev
|
|
|
242
242
|
import { createBus } from "nanoeffects";
|
|
243
243
|
import { contextPlugin } from "nanoeffects/context";
|
|
244
244
|
import {
|
|
245
|
-
|
|
245
|
+
actionsPlugin,
|
|
246
246
|
emitterPlugin,
|
|
247
247
|
modulePlugin,
|
|
248
248
|
} from "nanoeffects/actions";
|
|
@@ -258,8 +258,8 @@ export const bus = createBus()
|
|
|
258
258
|
.use(matchPlugin()) // bus.on takes a function that picks the events
|
|
259
259
|
.use(emitterPlugin()); // bus.emitter(creator)
|
|
260
260
|
|
|
261
|
-
//
|
|
262
|
-
export const
|
|
261
|
+
// actionsPlugin — the registry of action handlers; keep it to bind handlers later
|
|
262
|
+
export const actions = bus.use(actionsPlugin());
|
|
263
263
|
|
|
264
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]));
|
|
@@ -274,12 +274,12 @@ 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 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.
|
|
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.actions`. 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:
|
|
281
281
|
|
|
282
|
-
- `
|
|
282
|
+
- `actionsPlugin` binds its list in the registry that the bus has, so it can come before or after `modulePlugin`. A custom `bus.actions = { run }` is replaced.
|
|
283
283
|
- `effectsPlugin` and `contextPlugin` do not replace a field that is on `ctx` already, so a scope wins in any order.
|
|
284
284
|
- `devPlugin()` finds the app that `modulePlugin` put on the bus (`bus.app`). `devPlugin({ app })` still works.
|
|
285
285
|
- `matchPlugin` and `devPlugin` both wrap `bus.on`; `devPlugin` counts a function that picks events under `"*"`, and its `disconnect` leaves a wrapper that was put on top of it.
|
package/docs/query-libraries.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Use nanoeffects with React Query and SWR
|
|
2
2
|
|
|
3
|
-
Read this page if you fetch data with [React Query](https://tanstack.com/query) (TanStack Query) or [SWR](https://swr.vercel.app) and want `retry`, `timeout` and the abort of nanoeffects inside the request. It shows one
|
|
3
|
+
Read this page if you fetch data with [React Query](https://tanstack.com/query) (TanStack Query) or [SWR](https://swr.vercel.app) and want `retry`, `timeout` and the abort of nanoeffects inside the request. It shows one action that both libraries call.
|
|
4
4
|
|
|
5
5
|
You need a React project, `nanoeffects`, and one of the two libraries. `api` below is your own client: replace it with your requests.
|
|
6
6
|
|
|
@@ -16,9 +16,9 @@ The libraries and nanoeffects do different jobs, so each keeps its own.
|
|
|
16
16
|
|
|
17
17
|
`isLoading` and `data` come from the library, not from `emit`. `await bus.emit(...)` resolves once, with the result, so it cannot report that the request is still running.
|
|
18
18
|
|
|
19
|
-
## Make the
|
|
19
|
+
## Make the action
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
An action with a handler is the request itself. It lives in files that know nothing about React: the bus, the action with its handler, and the component.
|
|
22
22
|
|
|
23
23
|
```js
|
|
24
24
|
// bus.js
|
|
@@ -34,11 +34,11 @@ The handler tries the request up to three times and gives up after five seconds
|
|
|
34
34
|
|
|
35
35
|
```js
|
|
36
36
|
// user.js
|
|
37
|
-
import {
|
|
37
|
+
import { createAction } from "nanoeffects/actions";
|
|
38
38
|
import { retry, timeout } from "nanoeffects/effects";
|
|
39
39
|
|
|
40
|
-
//
|
|
41
|
-
export const getUser =
|
|
40
|
+
// createAction with a handler — emit returns what the handler returns
|
|
41
|
+
export const getUser = createAction("user/get", async (id, ctx) => {
|
|
42
42
|
// one try: the request, with the signal of timeout so it can stop it
|
|
43
43
|
const load = ({ signal }) => api.getUser(id, { signal });
|
|
44
44
|
// timeout — fail the try after 5 s
|
|
@@ -73,13 +73,13 @@ export function Profile({ id }) {
|
|
|
73
73
|
}
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
The
|
|
76
|
+
The action rejects when all tries fail, so React Query shows the `error`. React Query counts its own `retry` too: set `retry: false` in `useQuery` when the handler already retries, or the tries multiply.
|
|
77
77
|
|
|
78
78
|
## Call it from SWR
|
|
79
79
|
|
|
80
80
|
<!-- docs-check: continue -->
|
|
81
81
|
|
|
82
|
-
SWR does not pass a signal to the fetcher, so there is nothing to give to `emit`. The
|
|
82
|
+
SWR does not pass a signal to the fetcher, so there is nothing to give to `emit`. The action still brings `retry` and `timeout`.
|
|
83
83
|
|
|
84
84
|
```jsx
|
|
85
85
|
// Profile.jsx
|
|
@@ -103,7 +103,7 @@ export function Profile({ id }) {
|
|
|
103
103
|
|
|
104
104
|
<!-- docs-check: continue -->
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
An action with a handler gives its result only to the caller. If a store or another module must react to a loaded user, the handler can say so:
|
|
107
107
|
|
|
108
108
|
```js
|
|
109
109
|
// user-events.js
|
|
@@ -113,14 +113,14 @@ import { createAction } from "nanoeffects/actions";
|
|
|
113
113
|
export const userLoaded = createAction("user/loaded");
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
-
Send it from the handler with `ctx.emit(userLoaded(user))`. Do not replace the
|
|
116
|
+
Send it from the handler with `ctx.emit(userLoaded(user))`. Do not replace the action with `createJob` here: a job handles the error itself and resolves with `undefined`, so React Query would never see the failure. Use a job when nothing waits for the result and the app shows a loading state from the bus, see [Show a loading state with a job](actions.md#show-a-loading-state-with-a-job).
|
|
117
117
|
|
|
118
118
|
## Know the limits
|
|
119
119
|
|
|
120
|
-
- **Without the plugin, `{ signal }` is ignored.** The
|
|
120
|
+
- **Without the plugin, `{ signal }` is ignored.** The action runs, but the abort does not reach it. Call `bus.use(signalPlugin())` once, where the bus is made.
|
|
121
121
|
- **Only the call forms take it.** `bus.emit(getUser(id), { signal })` works; `bus.emit("user/get", id, { signal })` does not.
|
|
122
|
-
- **A handler that returns without a promise is finished with the call**, so a signal that aborts later does not reach it.
|
|
123
|
-
- **Give each request its own signal for an event.** For a
|
|
124
|
-
- **The signal reaches the tasks that this `emit` starts**, with the listeners of the
|
|
122
|
+
- **A handler that returns without a promise is finished with the call**, so a signal that aborts later does not reach it. Actions that fetch are async, so this only matters for a sync handler that sets up a callback.
|
|
123
|
+
- **Give each request its own signal for an event.** For an action with a handler or a job the link ends when it settles. For `bus.emit(action, { signal })` it stays until the signal aborts.
|
|
124
|
+
- **The signal reaches the tasks that this `emit` starts**, with the listeners of the action's type and the `ctx.run` children. A separate `bus.emit` that the handler makes is not linked: pass `{ signal: ctx.signal }` to it.
|
|
125
125
|
|
|
126
126
|
A whole small app, with a search, a filter, pages and a detail panel, is in the [Rick and Morty example](../examples/rickandmorty).
|
package/docs/scope.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Pin context fields to
|
|
1
|
+
# Pin context fields to an action, a module or a call
|
|
2
2
|
|
|
3
|
-
Optional, for domain logic. Use it when one
|
|
3
|
+
Optional, for domain logic. Use it when one action needs its own client, one module its own logger, or one test its own fake, and a field on the whole bus ([context fields](context.md)) is too wide.
|
|
4
4
|
|
|
5
5
|
A **scope** is a plain object. Every key goes on `ctx` as it is (`cache`, `api`, `logger`), except one: `$effects`. Its functions take `{ signal }` as the first parameter, like `retry(ctx, fn)`, and go on `ctx` bound to the task, so you call `ctx.fetchJson(url)` instead of `fetchJson(ctx, url)`.
|
|
6
6
|
|
|
@@ -19,21 +19,21 @@ import { scopePlugin } from "nanoeffects/scope";
|
|
|
19
19
|
export const bus = createBus().use(scopePlugin());
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
The bus is in one file and the
|
|
22
|
+
The bus is in one file and the actions in files named after their domain, so the next example imports `bus` from here. Each section after it keeps its own `bus.js` and domain file, as if it were an app of its own.
|
|
23
23
|
|
|
24
24
|
- Without `scopePlugin` a scope is **ignored**: no error and no warning. If a field is missing from `ctx`, check this first.
|
|
25
25
|
- Use it in any order with the other plugins: a scope wins over `effectsPlugin`, `contextPlugin` and `createBus({ context })`.
|
|
26
26
|
|
|
27
|
-
## Give
|
|
27
|
+
## Give an action its own fields
|
|
28
28
|
|
|
29
29
|
<!-- docs-check: continue -->
|
|
30
30
|
|
|
31
31
|
```js
|
|
32
32
|
// user.js
|
|
33
|
-
import {
|
|
33
|
+
import { createAction } from "nanoeffects/actions";
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
export const getUser =
|
|
35
|
+
// createAction — third argument is the scope of this action
|
|
36
|
+
export const getUser = createAction(
|
|
37
37
|
"user/get",
|
|
38
38
|
(id, { api, cache }) => {
|
|
39
39
|
// api and cache are on ctx for this command only
|
|
@@ -50,7 +50,7 @@ import { getUser } from "./user";
|
|
|
50
50
|
const user = await bus.emit(getUser("1")); // the handler has api and cache
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
The listeners of `"user/get"` get the same fields when the
|
|
53
|
+
The listeners of `"user/get"` get the same fields when the action is emitted, because the fields belong to the call.
|
|
54
54
|
|
|
55
55
|
## Bind an effect to the task
|
|
56
56
|
|
|
@@ -58,14 +58,14 @@ An effect is a function with `{ signal }` as its first parameter. In `$effects`
|
|
|
58
58
|
|
|
59
59
|
```js
|
|
60
60
|
// feed.js
|
|
61
|
-
import {
|
|
61
|
+
import { createAction } from "nanoeffects/actions";
|
|
62
62
|
import { delay, race } from "nanoeffects/effects";
|
|
63
63
|
|
|
64
64
|
// your own effect: first parameter is the task (only its signal is used)
|
|
65
65
|
const fetchJson = (ctx, url) =>
|
|
66
66
|
fetch(url, { signal: ctx.signal }).then((response) => response.json());
|
|
67
67
|
|
|
68
|
-
export const loadFeed =
|
|
68
|
+
export const loadFeed = createAction(
|
|
69
69
|
"feed/load",
|
|
70
70
|
(url, { fetchJson, race, delay }) =>
|
|
71
71
|
// race, delay, fetchJson — bound to this task: no ctx argument
|
|
@@ -111,9 +111,9 @@ export const bus = createBus().use(scopePlugin());
|
|
|
111
111
|
|
|
112
112
|
```js
|
|
113
113
|
// user.js
|
|
114
|
-
import {
|
|
114
|
+
import { createAction } from "nanoeffects/actions";
|
|
115
115
|
|
|
116
|
-
export const getUser =
|
|
116
|
+
export const getUser = createAction(
|
|
117
117
|
"user/get",
|
|
118
118
|
(id, { api }) => api.getUser(id),
|
|
119
119
|
{ api },
|
|
@@ -134,7 +134,7 @@ await bus.emit(getUser("1"), { api: fakeApi });
|
|
|
134
134
|
|
|
135
135
|
Rules for the second argument:
|
|
136
136
|
|
|
137
|
-
- It works with
|
|
137
|
+
- It works with the call of an action (`emit(getUser("1"), scope)`, `ctx.emit(loaded(user), scope)`).
|
|
138
138
|
- It does not work with the string form: in `emit("user/get", payload)` the second argument is the payload.
|
|
139
139
|
- It can replace `$effects` too: `{ $effects: { delay: instantDelay } }`.
|
|
140
140
|
- Fields of the library (`signal`, `emit`, …) in it are ignored.
|
|
@@ -199,13 +199,13 @@ In `$effects` a function loses its first parameter, and TypeScript cannot keep a
|
|
|
199
199
|
|
|
200
200
|
```js
|
|
201
201
|
// pick.js
|
|
202
|
-
import {
|
|
202
|
+
import { createAction } from "nanoeffects/actions";
|
|
203
203
|
import { effect } from "nanoeffects/scope";
|
|
204
204
|
|
|
205
205
|
// effect — marks the function, so a scope binds it to the task; the editor keeps its generic
|
|
206
206
|
const first = effect((ctx, items) => items[0]);
|
|
207
207
|
|
|
208
|
-
export const pick =
|
|
208
|
+
export const pick = createAction("pick", (items, { first }) => first(items), {
|
|
209
209
|
first, // a plain key of the scope, not an entry of `$effects`
|
|
210
210
|
});
|
|
211
211
|
```
|
|
@@ -215,7 +215,7 @@ Call `effect` where you declare the function, as above, and not inline in the sc
|
|
|
215
215
|
`Effect<F>` (TypeScript): declare how the effect looks **after** it is bound, and keep it in `$effects`.
|
|
216
216
|
|
|
217
217
|
```ts
|
|
218
|
-
import {
|
|
218
|
+
import { createAction } from "nanoeffects/actions";
|
|
219
219
|
import type { Effect } from "nanoeffects/effects";
|
|
220
220
|
|
|
221
221
|
// First — checker-only: how a handler calls the effect (no ctx)
|
|
@@ -224,7 +224,7 @@ type First = <T>(items: T[]) => T;
|
|
|
224
224
|
// Effect<First> — the function takes the task first and is typed as First once bound
|
|
225
225
|
const first: Effect<First> = (ctx, items) => items[0];
|
|
226
226
|
|
|
227
|
-
const pick =
|
|
227
|
+
const pick = createAction(
|
|
228
228
|
"pick",
|
|
229
229
|
(id: string, { first }) => first([id]), // string
|
|
230
230
|
{ $effects: { first } },
|
|
@@ -235,14 +235,14 @@ const pick = createCommand(
|
|
|
235
235
|
|
|
236
236
|
## Type the fields (optional)
|
|
237
237
|
|
|
238
|
-
A handler of
|
|
238
|
+
A handler of an action, job or module with a scope sees the fields of the scope in `ctx`, typed from the values you gave. You write no `WithContext`.
|
|
239
239
|
|
|
240
240
|
```ts
|
|
241
|
-
import {
|
|
241
|
+
import { createAction } from "nanoeffects/actions";
|
|
242
242
|
|
|
243
243
|
type User = { id: string; name: string };
|
|
244
244
|
|
|
245
|
-
const getUser =
|
|
245
|
+
const getUser = createAction(
|
|
246
246
|
"user/get",
|
|
247
247
|
async (id: string, ctx) => {
|
|
248
248
|
ctx.cache; // Map<string, User>, from the scope below
|
|
@@ -260,7 +260,7 @@ const getUser = createCommand(
|
|
|
260
260
|
## Limits
|
|
261
261
|
|
|
262
262
|
- No plugin, no scope: see [Turn scopes on](#turn-scopes-on).
|
|
263
|
-
- The fields of the bus are not in the handler of
|
|
263
|
+
- The fields of the bus are not in the handler of an action made with `createAction(…, scope)` unless you type `ctx` yourself; the fields of the scope are.
|
|
264
264
|
- The string form of `emit` takes no scope.
|
|
265
265
|
- `effect(fn)` works only as a value of a scope, not as an entry of `$effects`.
|
|
266
|
-
- A scope on
|
|
266
|
+
- A scope on an action, job or module is typed without the events of a bus, so `take` in its `$effects` takes any event name.
|
package/docs/server.md
CHANGED
|
@@ -16,9 +16,9 @@ The fix is one `AbortController` for each unit of work that the client owns, and
|
|
|
16
16
|
| A WebSocket | the socket connects | the socket closes |
|
|
17
17
|
| A queued job | the job is accepted | the server shuts down |
|
|
18
18
|
|
|
19
|
-
## Make the bus and the
|
|
19
|
+
## Make the bus and the action
|
|
20
20
|
|
|
21
|
-
The bus and the
|
|
21
|
+
The bus and the action live in files that know nothing about HTTP, so the same action can run in a route, a test or a worker.
|
|
22
22
|
|
|
23
23
|
```js
|
|
24
24
|
// bus.js
|
|
@@ -30,15 +30,15 @@ import { signalPlugin } from "nanoeffects/signal";
|
|
|
30
30
|
export const bus = createBus().use(signalPlugin());
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
The
|
|
33
|
+
The action loads a user. It tries the request up to three times, and gives up on each try after five seconds. `ctx.signal` is the signal of the task: it aborts when the controller of the request aborts.
|
|
34
34
|
|
|
35
35
|
```js
|
|
36
36
|
// user.js
|
|
37
|
-
import {
|
|
37
|
+
import { createAction } from "nanoeffects/actions";
|
|
38
38
|
import { retry, timeout } from "nanoeffects/effects";
|
|
39
39
|
|
|
40
|
-
//
|
|
41
|
-
export const getUser =
|
|
40
|
+
// createAction — a command with a handler: emit returns what the handler returns
|
|
41
|
+
export const getUser = createAction("user/get", async (id, ctx) => {
|
|
42
42
|
// one try: the request, with the signal of timeout so it can stop it
|
|
43
43
|
const load = ({ signal }) => api.getUser(id, { signal });
|
|
44
44
|
// timeout — fail the try after 5 s
|
|
@@ -91,7 +91,7 @@ app.get("/users/:id", async (request, reply) => {
|
|
|
91
91
|
});
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
After an abort the
|
|
94
|
+
After an abort the action rejects with the abort reason. The `catch` above ignores it when the connection is gone, because there is nobody to answer.
|
|
95
95
|
|
|
96
96
|
## Stop the server
|
|
97
97
|
|
|
@@ -123,17 +123,17 @@ The route above fills `inFlight` and empties it in the `close` listener. The [ap
|
|
|
123
123
|
|
|
124
124
|
A socket lasts minutes and carries several tasks (a subscription, a heartbeat), so the controller belongs to the socket and every `emit` for it gets the same signal. Closing the socket stops all of them.
|
|
125
125
|
|
|
126
|
-
First the
|
|
126
|
+
First the action. It sends a price now and then every second, until its task is aborted.
|
|
127
127
|
|
|
128
128
|
```js
|
|
129
129
|
// prices.js
|
|
130
|
-
import {
|
|
130
|
+
import { createAction } from "nanoeffects/actions";
|
|
131
131
|
import { poll } from "nanoeffects/effects";
|
|
132
132
|
import { latest } from "nanoeffects/policies";
|
|
133
133
|
|
|
134
|
-
//
|
|
134
|
+
// createAction — a command with a handler
|
|
135
135
|
// latest — a new subscription stops the old one; key — but only the old one of the same socket
|
|
136
|
-
export const watchPrices =
|
|
136
|
+
export const watchPrices = createAction(
|
|
137
137
|
"prices/watch",
|
|
138
138
|
latest(
|
|
139
139
|
async (call, ctx) => {
|
|
@@ -147,7 +147,7 @@ export const watchPrices = createCommand(
|
|
|
147
147
|
);
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
-
Without the `key`, one policy serves the whole
|
|
150
|
+
Without the `key`, one policy serves the whole action, and a new subscription on one socket would stop the subscription on another.
|
|
151
151
|
|
|
152
152
|
Then the route. Each socket gets an id and a controller.
|
|
153
153
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Know what is stable
|
|
2
|
+
|
|
3
|
+
Use this page to decide what you can rely on. It says which imports are stable, what counts as a breaking change, which versions are supported, and which call forms stay.
|
|
4
|
+
|
|
5
|
+
## See which entries are stable
|
|
6
|
+
|
|
7
|
+
A stable entry follows the rules below. An unstable entry can change in any minor release; every change is written in the [CHANGELOG](../CHANGELOG.md), and nothing promises a deprecation period first.
|
|
8
|
+
|
|
9
|
+
| Import | Status | Note |
|
|
10
|
+
| ------ | ------ | ---- |
|
|
11
|
+
| `nanoeffects` | Stable | `createBus` and the types of a bus |
|
|
12
|
+
| `nanoeffects/effects` | Stable | `retry`, `timeout`, `poll`, `take`, `delay`, `all`, `allSettled`, `race`, `backoff`, `cancellable`, `effectsPlugin` |
|
|
13
|
+
| `nanoeffects/policies` | Stable | `latest`, `leading`, `debounce`, `throttle`, `queue`, `withPolicy` |
|
|
14
|
+
| `nanoeffects/actions` | Stable | `createAction`, `createJob`, `createModule` and their plugins |
|
|
15
|
+
| `nanoeffects/trace` | Stable: the contract | `Tracer`, `Trace`, `TraceEvent`, `TraceLogger`, `Phase`, `EffectName` and `tracePlugin` |
|
|
16
|
+
| `nanoeffects/trace` | Unstable: the tools | `createTracer`, `consoleLogger`, `performanceLogger`, `createTraceRecorder`, `reduxDevtools` and their option types |
|
|
17
|
+
| `nanoeffects/scope` | Unstable | `scopePlugin`, `effect` |
|
|
18
|
+
| `nanoeffects/match` | Unstable | `matchPlugin` |
|
|
19
|
+
| `nanoeffects/signal` | Unstable | `signalPlugin` |
|
|
20
|
+
| `nanoeffects/context` | Unstable | `contextPlugin` |
|
|
21
|
+
| `nanoeffects/oop` | Unstable | `Emitter`, `extendBus`, `On`, `Once`, `WithBus`, `disposeBus` |
|
|
22
|
+
| `nanoeffects/observable` | Unstable | `createObserve`, `Subject` |
|
|
23
|
+
| `nanoeffects/redux` | Unstable | `createEffectsMiddleware` |
|
|
24
|
+
| `nanoeffects/react` | Unstable | `reactPlugin`, `usePolicy` |
|
|
25
|
+
| `nanoeffects/svelte` | Unstable | `sveltePlugin` |
|
|
26
|
+
| `nanoeffects/vue` | Unstable | `vuePlugin` |
|
|
27
|
+
| `nanoeffects/dev` | Unstable | `devPlugin` |
|
|
28
|
+
|
|
29
|
+
The adapters (`react`, `vue`, `svelte`, `redux`) are unstable because they follow the APIs of other libraries and have had little use. They can become stable after the library has been in use for a while.
|
|
30
|
+
|
|
31
|
+
## Know what is a breaking change
|
|
32
|
+
|
|
33
|
+
For a stable entry, these changes are breaking:
|
|
34
|
+
|
|
35
|
+
- A change in what a call does at run time.
|
|
36
|
+
- A change in a type that stops code that compiled before from compiling.
|
|
37
|
+
- A removed or renamed export.
|
|
38
|
+
|
|
39
|
+
A stricter type that only finds mistakes in code that was already wrong is not breaking. It ships in a minor release with a note in the CHANGELOG.
|
|
40
|
+
|
|
41
|
+
These are not covered by the promise:
|
|
42
|
+
|
|
43
|
+
- Any unstable entry.
|
|
44
|
+
- The text of the messages of `nanoeffects/dev`.
|
|
45
|
+
- The payload details of trace events. The shape of an event (`id`, `parentId`, `rootId`, `type`, `effect`, `phase`) is covered; the contents of `payload` are not.
|
|
46
|
+
|
|
47
|
+
Before version 1.0.0, a breaking change ships in a minor release under `### Breaking` in the CHANGELOG. From 1.0.0 it ships only in a major release.
|
|
48
|
+
|
|
49
|
+
A deprecated export keeps working and is removed only in a major release. `RaceStarter` is deprecated: use `StarterOf` instead.
|
|
50
|
+
|
|
51
|
+
## Check the supported versions
|
|
52
|
+
|
|
53
|
+
| What | Supported |
|
|
54
|
+
| ---- | --------- |
|
|
55
|
+
| Node.js, the library | 18, 20, 22 and 24 (a smoke test runs the built package on each) |
|
|
56
|
+
| Node.js, the dev tools and the trace in a browser | 22 and newer |
|
|
57
|
+
| TypeScript | 5.9 and 7. The built declaration files are type-checked with TypeScript 5.9 in CI, and the source with TypeScript 7. Older versions are not tested. |
|
|
58
|
+
| React | 18 or newer (`nanoeffects/react`) |
|
|
59
|
+
| Vue | 3.3 or newer (`nanoeffects/vue`) |
|
|
60
|
+
| Svelte | 5 or newer (`nanoeffects/svelte`) |
|
|
61
|
+
|
|
62
|
+
The library has no dependencies. React, Vue and Svelte are optional peer dependencies of their own entries only.
|
|
63
|
+
|
|
64
|
+
## Rely on these call forms
|
|
65
|
+
|
|
66
|
+
`retry`, `timeout` and `poll` have three call forms, and all three stay:
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
import { createBus } from "nanoeffects";
|
|
70
|
+
import { race, retry } from "nanoeffects/effects";
|
|
71
|
+
|
|
72
|
+
const bus = createBus();
|
|
73
|
+
|
|
74
|
+
bus.on("open", async (id, ctx) => {
|
|
75
|
+
// with ctx: you pass the context, the function gets the signal
|
|
76
|
+
const user = await retry(ctx, ({ signal }) => fetchUser(id, { signal }), { times: 3 });
|
|
77
|
+
|
|
78
|
+
// as a starter: a group (race, all, allSettled, timeout) calls it with its child
|
|
79
|
+
const result = await race(ctx, {
|
|
80
|
+
user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// as a Task: a function of (payload, ctx), which bus.on and ctx.run call
|
|
85
|
+
bus.on("reload", retry((id, { signal }) => fetchUser(id, { signal }), { times: 3 }));
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The forms are told apart by the arguments: a context first is the form with `ctx`; a function called with a payload and a context is the Task; the same function called by a group with one argument, its child, is the starter. The rule is part of the contract and does not depend on how many parameters your function declares.
|
|
89
|
+
|
|
90
|
+
`all` and `allSettled` take options only in the form with `ctx`, `all(ctx, effects, { concurrency })`. The form without `ctx` has one argument, so a second one would look like the `ctx` form. `race` takes no `concurrency`: it ends at the first result, so a starter that waited for a free slot would never run. See [Effects](effects.md#run-several-starters-together).
|
package/docs/starter.md
CHANGED
|
@@ -100,7 +100,7 @@ npm create nanoeffects@latest my-app -- --template domains-ts
|
|
|
100
100
|
|
|
101
101
|
For a bigger example, a chat in which parts react to each other, see [Build a chat with entities, features, modules and a small server](chat-example.md). It has more layers than `domains-ts`: when domains must react to each other, it keeps them from importing each other by splitting them into entities and features.
|
|
102
102
|
|
|
103
|
-
The commands, jobs and modules themselves are explained in [Actions,
|
|
103
|
+
The commands, jobs and modules themselves are explained in [Actions, 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).
|
|
104
104
|
|
|
105
105
|
## Read the files
|
|
106
106
|
|
package/docs/tracing.md
CHANGED
|
@@ -138,7 +138,7 @@ Give the flow a `traceGroupId` in the scope of the command, the job or the actio
|
|
|
138
138
|
|
|
139
139
|
```js
|
|
140
140
|
import { createBus } from "nanoeffects";
|
|
141
|
-
import {
|
|
141
|
+
import { createAction } from "nanoeffects/actions";
|
|
142
142
|
import { consoleLogger, createTracer, tracePlugin } from "nanoeffects/trace";
|
|
143
143
|
|
|
144
144
|
// createTracer: sends each trace event to the logger; consoleLogger: prints one line each
|
|
@@ -149,7 +149,7 @@ const bus = createBus({ trace })
|
|
|
149
149
|
.use(tracePlugin());
|
|
150
150
|
|
|
151
151
|
// the scope names the flow; traceGroupId is for the tracer, not a field of ctx
|
|
152
|
-
const pay =
|
|
152
|
+
const pay = createAction("checkout/pay", async () => {}, {
|
|
153
153
|
traceGroupId: "checkout",
|
|
154
154
|
});
|
|
155
155
|
```
|