nanoeffects 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +50 -55
  2. package/dist/{action-rqZEnRIp.d.ts → action-creator-D48fp13T.d.ts} +66 -74
  3. package/dist/actions.d.ts +54 -20
  4. package/dist/actions.js +1 -1
  5. package/dist/bus-Cho463Sq.js +1 -0
  6. package/dist/dev.d.ts +2 -2
  7. package/dist/effects.d.ts +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +1 -1
  10. package/dist/{module-BSCdR9D1.d.ts → module-DVX8aYlq.d.ts} +39 -25
  11. package/dist/notrace/{action-rqZEnRIp.d.ts → action-creator-D48fp13T.d.ts} +66 -74
  12. package/dist/notrace/actions.d.ts +90 -42
  13. package/dist/notrace/actions.js +1 -1
  14. package/dist/notrace/bus-B12SvxLv.js +1 -0
  15. package/dist/notrace/effects.d.ts +1 -1
  16. package/dist/notrace/index.d.ts +2 -2
  17. package/dist/notrace/index.js +1 -1
  18. package/dist/notrace/policies.d.ts +1 -1
  19. package/dist/notrace/redux.d.ts +1 -1
  20. package/dist/notrace/redux.js +1 -1
  21. package/dist/observable.d.ts +1 -1
  22. package/dist/policies.d.ts +1 -1
  23. package/dist/redux.d.ts +1 -1
  24. package/dist/redux.js +1 -1
  25. package/dist/trace.d.ts +1 -1
  26. package/docs/README.md +24 -9
  27. package/docs/actions.md +118 -42
  28. package/docs/context.md +27 -8
  29. package/docs/effects.md +152 -73
  30. package/docs/errors.md +23 -10
  31. package/docs/guide.md +236 -129
  32. package/docs/hmr.md +25 -13
  33. package/docs/observable.md +10 -9
  34. package/docs/package.md +14 -0
  35. package/docs/policies.md +92 -30
  36. package/docs/semantics.md +5 -3
  37. package/docs/stores.md +96 -22
  38. package/docs/tracing.md +69 -54
  39. package/package.json +5 -4
  40. package/dist/bus-CRnlwmQ4.js +0 -1
  41. package/dist/notrace/bus-Dx0l9X6h.js +0 -1
  42. package/dist/notrace/event-0RIBGYqS.js +0 -1
@@ -1,4 +1,4 @@
1
- import { N as Task, k as EventMap } from "./action-rqZEnRIp.js";
1
+ import { o as EventMap, u as Task } from "./action-creator-D48fp13T.js";
2
2
  //#region src/policies/keyed.d.ts
3
3
  /** Options shared by every policy that can keep its state per key. */
4
4
  type KeyOption<P> = {
package/dist/redux.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { k as EventMap, o as Bus, s as BusOptions } from "./action-rqZEnRIp.js";
1
+ import { _ as Bus, o as EventMap, v as BusOptions } from "./action-creator-D48fp13T.js";
2
2
  //#region src/store/redux/middleware.d.ts
3
3
  /** What Redux passes through a middleware: an action is a plain object with a `type`. */
4
4
  type ReduxAction = {
package/dist/redux.js CHANGED
@@ -1 +1 @@
1
- import{n as e}from"./onAbort-BgMfspK4.js";import{t}from"./bus-CRnlwmQ4.js";const n=e=>typeof e==`object`&&!!e&&`type`in e,r=(e,t={})=>{let r=e.emit;return()=>e=>i=>{let a=e(i);return n(i)&&(t.filter?.(i)??!0)&&r(i),a}},i=(n={})=>{let{filter:i,context:a,onError:o,...s}=n,c,l=/* @__PURE__ */ new WeakMap,u=e=>({...a,store:c&&{getState:c.getState,dispatch:t=>{typeof t==`object`&&t&&l.set(t,e.type);let n=t;return c?.dispatch(n)}}}),d=t({...s,onError:o??((t,{type:n})=>{e(`handler of "${n}" failed`,t)}),context:u}),f=r(d,{filter:e=>l.get(e)!==e.type&&(i?.(e)??!0)});return{bus:d,middleware:e=>(c=e,f())}};export{i as createEffectsMiddleware};
1
+ import{n as e}from"./onAbort-BgMfspK4.js";import{n as t}from"./bus-Cho463Sq.js";const n=e=>typeof e==`object`&&!!e&&`type`in e,r=(e,t={})=>{let r=e.emit;return()=>e=>i=>{let a=e(i);return n(i)&&(t.filter?.(i)??!0)&&r(i),a}},i=(n={})=>{let{filter:i,context:a,onError:o,...s}=n,c,l=/* @__PURE__ */ new WeakMap,u=e=>({...a,store:c&&{getState:c.getState,dispatch:t=>{typeof t==`object`&&t&&l.set(t,e.type);let n=t;return c?.dispatch(n)}}}),d=t({...s,onError:o??((t,{type:n})=>{e(`handler of "${n}" failed`,t)}),context:u}),f=r(d,{filter:e=>l.get(e)!==e.type&&(i?.(e)??!0)});return{bus:d,middleware:e=>(c=e,f())}};export{i as createEffectsMiddleware};
package/dist/trace.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { E as Tracer, F as TraceEvent, I as TraceLogger, P as Trace } from "./action-rqZEnRIp.js";
1
+ import { R as Tracer, d as Trace, f as TraceEvent, p as TraceLogger } from "./action-creator-D48fp13T.js";
2
2
  //#region src/trace/console.d.ts
3
3
  type ConsoleLoggerOptions = {
4
4
  /** Defaults to `globalThis.console`. */
package/docs/README.md CHANGED
@@ -1,15 +1,30 @@
1
1
  # Documentation
2
2
 
3
- Start with the [Guide](guide.md): from a bare bus to modules, one step at a time. The pages below are the reference.
3
+ ## Start here
4
4
 
5
- - [Guide](guide.md)
6
- - [Policies](policies.md): `latest`, `leading`, `debounce`, `throttle`, `queue`, `key`
7
- - [Effects and cancellation](effects.md): `retry`, `timeout`, `poll`, `take`, `all`, `allSettled`, `race`, `ctx.on`
5
+ The [Guide](guide.md) is the ladder: small bus → policies for async UI → effects when you need them → commands and modules for domain logic off the UI.
6
+
7
+ Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-checked and tested).
8
+
9
+ ## Jump by project need
10
+
11
+ | Project shape | First page |
12
+ | ------------- | ---------- |
13
+ | Small app / widget (rare races) | [Guide § Create a bus](guide.md#1-create-a-bus) |
14
+ | SPA with async UI | [Policies](policies.md), [Guide § latest](guide.md#3-abort-the-previous-run-with-latest) |
15
+ | Unreliable network / long flows | [Effects](effects.md) — one helper per job, then the rest as reference |
16
+ | Domain logic off the UI | [Actions, commands, modules](actions.md) |
17
+ | Already on Redux / Zustand / MobX | [Stores](stores.md) |
18
+
19
+ ## Reference
20
+
21
+ - [Policies](policies.md) — `latest`, `leading`, `debounce`, `throttle`, `queue`, `key`
22
+ - [Effects and cancellation](effects.md) — `retry`, `timeout`, `poll`, `take`, `all`, …
8
23
  - [Actions, commands, jobs and modules](actions.md)
9
- - [Your own fields in the context](context.md)
24
+ - [Context fields](context.md) — store or client on every task
10
25
  - [Errors and unheard events](errors.md)
11
- - [Tracing](tracing.md): loggers, Redux DevTools, sampling, the `notrace` build
12
- - [Redux, Zustand, MobX](stores.md)
13
- - [Observable: events as streams](observable.md): `createObserve` for RxJS and friends
14
- - [Hot reload and dev diagnostics](hmr.md): modules replaced by name, `devPlugin`
26
+ - [Package entries](package.md) — which import holds what
27
+ - [Tracing](tracing.md) — loggers, Redux DevTools, `notrace`
28
+ - [Observable](observable.md) — events as TC39 Observables
29
+ - [Hot reload and dev diagnostics](hmr.md)
15
30
  - [Semantics](semantics.md)
package/docs/actions.md CHANGED
@@ -1,68 +1,141 @@
1
1
  # Actions, commands, jobs and modules
2
2
 
3
- ## Actions, commands and jobs
3
+ Use this page when **domain logic lives on the bus**, off the UI: the UI emits and listens; handlers and modules own the work.
4
4
 
5
- `nanoeffects/actions` has the pieces to describe what a bus does, apart from the code that does it.
5
+ If you only need `on` / `emit` or policies, stay in the [Guide](guide.md) until you need a result from `emit` or a mountable domain.
6
6
 
7
- ```ts
7
+ Recipe without a UI framework: [`examples/modules.ts`](../examples/modules.ts).
8
+
9
+ ## Return a value from `emit` with a command
10
+
11
+ ```js
12
+ import { createBus } from "nanoeffects";
13
+ import { createCommand } from "nanoeffects/actions";
14
+
15
+ const bus = createBus();
16
+
17
+ // createCommand — emit runs the handler and returns its result
18
+ const double = createCommand("math/double", (value) => value * 2);
19
+ bus.emit(double(21)); // 42
20
+ ```
21
+
22
+ ## Describe events, commands, and jobs
23
+
24
+ `nanoeffects/actions` has the pieces to name what a bus does, apart from the code that does it.
25
+
26
+ ```js
8
27
  import { createAction, createCommand, createJob } from "nanoeffects/actions";
9
28
 
10
- // an event with a payload, as in Redux Toolkit
11
- // the payload types come from the event map of the bus: type Events = { "user/loaded": User; "user/failed": Error }
29
+ // createAction — event with a payload (like RTK createAction)
12
30
  const userLoaded = createAction("user/loaded");
13
31
  const loadFailed = createAction("user/failed");
14
32
 
15
- // a request for a result: emit it and get what its handler returns
16
- const getUser = createCommand("user/get", (id: string, { signal }) => fetchUser(id, { signal }));
33
+ // createCommand with a handler — emit returns what the handler returns
34
+ const getUser = createCommand("user/get", (id, { signal }) => {
35
+ return fetchUser(id, { signal });
36
+ });
17
37
 
18
- // a contract without a handler: the code that sends mail is elsewhere
19
- const sendMail = createCommand<Mail, void>("mail/send");
38
+ // createCommand without a handler — a contract; register the work elsewhere
39
+ const sendMail = createCommand("mail/send");
20
40
 
21
- // a command whose outcome is sent on as actions: the command and its two actions together
22
- const { command: loadUser, successAction, failAction } = createJob("user/load", fetchUser, {
23
- success: userLoaded, // an action creator that exists: used and returned as it is
24
- fail: "user/failed", // a string: the job makes the action creator
41
+ // createJob — command that also emits success / fail actions
42
+ const loadUser = createJob("user/load", fetchUser, {
43
+ success: userLoaded,
44
+ fail: "user/failed",
25
45
  });
26
46
  ```
27
47
 
28
- **Actions.** `createAction(type)` returns a creator, with no generics: it keeps the literal type, and the bus checks the payload against its event map (`bus.emit(userLoaded(5))` is an error when `"user/loaded"` is a `User`). Without an event map give the payload type, `createAction<User>("user/loaded")`. The creator: `userLoaded(user)` is `{ type, payload }`, `userLoaded.type` is the type, `userLoaded.match(action)` narrows an action. `createAction(type, prepare)` builds the payload from several arguments. A creator works as the event of its type: `bus.emit(userLoaded(user))`, `bus.on(userLoaded, fn)`.
48
+ ### Actions
49
+
50
+ `createAction(type)` returns a creator. Without generics it keeps the literal type; the bus checks the payload against its event map. Without an event map, give the payload type: `createAction<User>("user/loaded")`.
51
+
52
+ - `userLoaded(user)` → `{ type, payload }`
53
+ - `userLoaded.type` — the type string
54
+ - `userLoaded.match(action)` — narrows an action
55
+ - `createAction(type, prepare)` — build the payload from several arguments
56
+ - Works as the event of its type: `bus.emit(userLoaded(user))`, `bus.on(userLoaded, fn)`
29
57
 
30
- **Commands.** `bus.emit(command, payload)` tells the listeners of the command's type, then runs its handler as a task, with a context, and returns the result. The handler can be wrapped in a policy, `createCommand("search", latest(fn))`. Calling `command(payload)` runs its own handler without a bus.
58
+ ### Commands
31
59
 
32
- **Where handlers are registered.** The command may carry a handler. The bus gets the handlers bound to commands from a registry, `createCommands`, and a handler bound there wins over the command's own. The registry belongs to one bus, as an effector scope does with `fork({ handlers })`, so each bus (each test) can have its own.
60
+ Calling a command builds `{ type, payload }` like `createAction`. `bus.emit(command(payload))` (or `bus.emit(command, payload)`) tells the listeners of the command’s type, then runs its handler as a task and returns the result.
33
61
 
34
- ```ts
35
- import { createCommands, bindHandler } from "nanoeffects/actions";
62
+ - Wrap the handler in a policy: `createCommand("search", latest(fn))`.
63
+ - `command.match(action)` narrows like an action creator’s `match`.
36
64
 
37
- const bus = createBus<Events>();
38
- const commands = createCommands(bus, [bindHandler(sendMail, smtpSend)]); // attached to the bus
65
+ **Where handlers are registered.** The command may carry a handler. The bus can also take handlers from a registry (`commandsPlugin`). A handler bound there wins over the command’s own. Each bus (each test) has its own registry.
39
66
 
40
- const unbind = commands.handle(getUser, async () => fakeUser); // later, e.g. in a test
41
- unbind(); // the command's own handler is back; the latest binding wins
67
+ ```js
68
+ import { createBus } from "nanoeffects";
69
+ import {
70
+ bindHandler,
71
+ commandsPlugin,
72
+ createCommand,
73
+ } from "nanoeffects/actions";
74
+
75
+ const sendMail = createCommand("mail/send");
76
+ const getUser = createCommand("user/get");
77
+
78
+ const bus = createBus();
79
+
80
+ // commandsPlugin — registry of handlers for this bus
81
+ const commands = bus.use(
82
+ commandsPlugin([bindHandler(sendMail, smtpSend)]),
83
+ );
84
+
85
+ // later, e.g. in a test — latest binding wins
86
+ const unbind = commands.handle(getUser, async () => fakeUser);
87
+ unbind();
42
88
  ```
43
89
 
44
- A command with no handler anywhere throws `No handler for command "..."` from `emit`. The registry lives in `nanoeffects/actions`, so a bus without one carries none of this code; without it `emit(command, payload)` simply runs the command's own handler. `createCommands(bus)` sets the registry as `bus.commands`, and a second call on the same bus replaces it. There is no `commands` option in `createBus`; a custom runner is assigned, `bus.commands = { run }`.
90
+ A command with no handler anywhere throws `No handler for command "..."` from `emit` (the call itself only builds the action).
91
+
92
+ The registry lives in `nanoeffects/actions`, so a bus without one carries none of that code. Without a registry, `emit` runs the command’s own handler. `bus.use(commandsPlugin())` sets `bus.commands`. A second plugin on the same bus replaces it. There is no `commands` option in `createBus`; assign a custom runner with `bus.commands = { run }`.
93
+
94
+ ### Jobs
95
+
96
+ `createJob(type, handler, { success, fail })` makes a command that also sends its outcome as actions, when nobody waits for the result. It returns the command with `loadUser.success` and `loadUser.fail` on it.
97
+
98
+ - On success, emits `loadUser.success(result)`.
99
+ - On failure, emits `loadUser.fail(error)`.
100
+ - `success` / `fail` are a string (job makes an action creator) or an existing action creator. Missing ones become `<type>/success` or `<type>/fail`.
101
+ - `bus.emit(loadUser(id))` returns a promise of the result. Handler errors are always handled: they go to `fail` and the promise resolves with `undefined`. For a result that must reject, use a command.
102
+ - Outcome actions are emitted as the task that ran the job (also for a handler bound with `commands.handle`). Nothing is sent after abort.
103
+ - A missing handler is not an outcome: `emit` throws `No handler for command "..."`.
104
+ - Calling the job only builds `{ type, payload }`; the handler runs on `emit`.
105
+
106
+ ## Mount domains with modules
107
+
108
+ **Problem.** A domain needs several subscriptions and command bindings. With plain `bus.on` / `commands.handle` you must remember every unsubscribe.
45
109
 
46
- **Jobs.** `createJob(type, handler, { success, fail })` makes a command that sends its outcome on as actions, for when nobody waits for the result, and returns `{ command, successAction, failAction }`. When the handler succeeds, `successAction(result)` is emitted on the bus, and when it fails, `failAction(error)`.
110
+ **When.** You want one mount / one unmount for that domain. Skip modules while a few top-level `bus.on` calls are enough.
47
111
 
48
- - `success` and `fail` are a string, from which the job makes an action creator with `createAction` (the payload is the result, or the error), or an action creator that exists already, which is used and returned as it is. A missing one is made with the type `<type>/success` or `<type>/fail`.
49
- - `bus.emit(command, payload)` returns a promise of the result. An error of the handler always counts as handled: it goes to `failAction` and the promise resolves with `undefined`. For a result that must reject, use a command.
50
- - The actions are emitted as the task that ran the job, also for a handler bound with `commands.handle`, and are not sent once the task is aborted. A missing handler is not an outcome: `emit` throws `No handler for command "..."`.
51
- - Calling `command(payload)` directly runs only the handler.
112
+ `bus.use(modulePlugin(modules))` assembles an app from modules and returns `mount`, `unmount`, `isMounted`, `dispose`, and `commands`.
52
113
 
53
- ## Modules
114
+ ```js
115
+ import { createBus } from "nanoeffects";
116
+ import {
117
+ createCommand,
118
+ createModule,
119
+ modulePlugin,
120
+ } from "nanoeffects/actions";
54
121
 
55
- `bus.use(modulePlugin(modules))`, with `modulePlugin` from `nanoeffects/actions`, assembles an app from modules and returns `mount`, `unmount`, `isMounted`, `dispose` and `commands`, the registry of command handlers. A module is what a domain of the app needs: the commands it handles, the events it listens to, its sub-modules and its background tasks.
122
+ const getUser = createCommand("user/get");
123
+ const saveUser = createCommand("user/save", persistUser);
56
124
 
57
- ```ts
58
- const userModule = createModule<Events>("user", (m) => {
59
- m.handle(getUser, fetchUser); // a contract gets its handler here
60
- m.use(saveUser, authModule); // a command that carries a handler, and a sub-module
61
- m.on("login", (user, ctx) => ctx.emit("loaded", user));
62
- m.spawn(async (_, ctx) => { /* background work, ends with the module */ });
125
+ // createModule — one domain; unmount undoes everything below
126
+ const userModule = createModule("user", (m) => {
127
+ m.handle(getUser, fetchUser);
128
+ m.use(saveUser, authModule);
129
+ m.on("login", (user, ctx) => {
130
+ ctx.emit("loaded", user);
131
+ });
132
+ m.spawn(async (_, ctx) => {
133
+ /* background work; ends when the module unmounts */
134
+ });
63
135
  });
64
136
 
65
- const bus = createBus<Events>();
137
+ const bus = createBus();
138
+ // modulePlugin — mount modules on the bus
66
139
  const app = bus.use(modulePlugin([userModule]));
67
140
 
68
141
  const unmount = app.mount(orderModule); // later, e.g. after a lazy import
@@ -70,9 +143,12 @@ app.commands.handle(getUser, fakeUser); // in a test
70
143
  app.dispose(); // unmounts every module
71
144
  ```
72
145
 
73
- - **Unmounting undoes everything** the module did: its subscriptions, its bound commands, its sub-modules and its background tasks, which are aborted.
74
- - **A module is known by its name.** Mounting a module whose name is taken replaces the mounted one. That is what hot reload needs: see [hmr.md](hmr.md).
75
- - **The registry.** If `createCommands(bus)` was called, that registry is used; otherwise the module plugin attaches its own. Either way it is `bus.commands` and `app.commands`, so the commands that modules bind are the ones the bus runs.
146
+ - **Unmounting undoes everything** the module did: subscriptions, bound commands, sub-modules, and background tasks (aborted).
147
+ - **A module is known by its name.** Mounting a module whose name is taken replaces the mounted one (see [Hot reload](hmr.md)).
148
+ - **Registry.** If `bus.use(commandsPlugin())` ran first, that registry is used; otherwise the module plugin attaches its own. Either way it is `bus.commands` and `app.commands`.
76
149
  - A disposed app cannot mount modules.
77
- - A module context is a task context plus `handle` (give a command a handler: for a contract, or to override), `use` (bring sub-modules and commands that carry a handler: `m.use(getUser, saveUser, subModule)`, in any order; the returned function undoes just that call) and `spawn` (a background task, aborted on unmount).
78
- - A command that carries a handler keeps answering after its module is unmounted: the handler is its own, and `use` only brings the command and tracks it in the registry. Use `handle` for a handler that should live only as long as the module.
150
+ - Module context = task context plus `handle`, `use`, and `spawn`:
151
+ - `handle` — give a command a handler (contract, or override).
152
+ - `use` — bring sub-modules and commands that carry a handler; the returned function undoes that call.
153
+ - `spawn` — background task, aborted on unmount.
154
+ - A command that carries a handler keeps answering after unmount (`use` only tracks registration). Use `handle` for a handler that must live only as long as the module.
package/docs/context.md CHANGED
@@ -1,15 +1,34 @@
1
- # Your own fields in the context
1
+ # Put your own fields on every task
2
2
 
3
- Add fields to the context of every task of a bus: a store, a client, a logger. Describe them in the event map with `WithContext`, and give the values in `createBus({ context })`:
3
+ Optional: put a store, client, or logger on every task. Skip until many handlers need the same dependency.
4
+
5
+ ## Pass values at runtime
6
+
7
+ ```js
8
+ import { createBus } from "nanoeffects";
9
+
10
+ // createBus({ context }) — every task gets these fields
11
+ const bus = createBus({ context: { store } });
12
+
13
+ bus.on("save", (draft, { store }) => {
14
+ store.put(draft);
15
+ });
16
+ ```
17
+
18
+ That is enough in JavaScript. The fields appear on handlers, `ctx.run`, `ctx.on`, commands, modules, and child tasks of effects and policies.
19
+
20
+ Library fields (`signal`, `emit`, `run`, …) win on name clashes. `context` may be a function `(task) => fields` when the value depends on the task.
21
+
22
+ ## Type the fields (optional)
23
+
24
+ `WithContext` on the event map only tells TypeScript that handlers of **this** bus have those fields. It does not wire anything at runtime and is not app-wide — another bus can have different fields or none.
4
25
 
5
26
  ```ts
27
+ import { createBus, type WithContext } from "nanoeffects";
28
+
29
+ // Events — event name → payload
30
+ // WithContext — checker-only: handlers also have ctx.store
6
31
  type Events = { save: Draft } & WithContext<{ store: Store }>;
7
32
 
8
33
  const bus = createBus<Events>({ context: { store } });
9
- bus.on("save", (draft, { store }) => store.put(draft));
10
34
  ```
11
-
12
- The fields are in the context of handlers, `ctx.run`, listeners added with `ctx.on`, commands, modules and the child tasks of effects and policies, and the compiler checks them: a handler of a bus without `WithContext` has no `store`, and `context` must have the fields that the map names. Two buses can have different fields.
13
-
14
- - The fields of the library (`signal`, `emit`, `run`, ...) win when the names are the same.
15
- - `context` can also be a function, `(task) => fields`, called with `{ type, payload }` of each task, for fields that depend on the task.
package/docs/effects.md CHANGED
@@ -1,20 +1,122 @@
1
1
  # Effects and cancellation
2
2
 
3
- An abort only raises a flag: your code decides to stop. `nanoeffects/effects` has helpers for the usual cases.
3
+ Use this page for retry, timeouts, waiting, or parallel work — not as the first page for a small bus. Pick **one** helper for your job; the rest is reference.
4
4
 
5
- `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:
5
+ An abort only raises a flag. Your code decides to stop. Start with `retry` / `timeout` on a handler (see [Guide §5](guide.md#5-add-retry-and-timeout)).
6
6
 
7
- ```ts
7
+ ## Wrap a handler with retry and timeout
8
+
9
+ `retry`, `timeout`, and `poll` have two forms:
10
+
11
+ - **Task wrapper** — take a function `(payload, ctx) => result` and return one. Pass them into `on` next to policies (no `ctx` in their arguments).
12
+ - **Call inside a body** — take `ctx` first.
13
+
14
+ The first argument tells them apart: a function (`retry`), a number (`timeout`), or a number/function (`poll`) is a wrapper; an object is `ctx`.
15
+
16
+ ```js
17
+ import { createBus } from "nanoeffects";
18
+ import { poll, retry, timeout } from "nanoeffects/effects";
19
+ import { latest } from "nanoeffects/policies";
20
+
21
+ const bus = createBus();
22
+ const loadUser = (id, { signal }) => fetchUser(id, { signal });
23
+
24
+ // timeout — one try must finish within 5 s
25
+ const loadOnce = timeout(5000, loadUser);
26
+ // retry — up to 4 attempts with backoff
27
+ const loadWithRetry = retry(loadOnce, {
28
+ times: 4,
29
+ delay: (attempt) => 2 ** attempt * 100,
30
+ shouldRetry: (error) => !isFatal(error),
31
+ });
32
+ // latest — a newer load aborts the older retry chain
33
+ bus.on("load", latest(loadWithRetry));
34
+
35
+ // poll — call refresh every 10 s until the task aborts
36
+ bus.on("watch", poll(10_000, (_, { signal }) => refresh({ signal })));
37
+
38
+ // ctx.run — run a wrapped Task as a child (aborted with the handler)
39
+ bus.on("open", async (id, ctx) => {
40
+ const load = retry(timeout(5000, loadUser));
41
+ const user = await ctx.run(load, id);
42
+ ctx.emit("opened", user);
43
+ });
44
+
45
+ // ctx-first form — one call inside a body
46
+ bus.on("load", async (id, ctx) => {
47
+ const user = await retry(
48
+ ctx,
49
+ () => fetchUser(id, { signal: ctx.signal }),
50
+ { times: 4 },
51
+ );
52
+ const data = await timeout(ctx, 5000, ({ signal }) => {
53
+ return fetchData(id, { signal });
54
+ });
55
+ await poll(ctx, () => refresh(), 10_000);
56
+ ctx.emit("ready", { user, data });
57
+ });
58
+ ```
59
+
60
+ Use the wrapper for a whole handler or a whole `ctx.run`. Use the `ctx` form for one call in the middle of a body.
61
+
62
+ Each attempt or call of a wrapper gets its own child context. Abort of the caller reaches it. Abort of one attempt (a timeout) does not reach the caller. The attempt number is passed only in the `ctx` form.
63
+
64
+ ### Options for retry, timeout, and poll
65
+
66
+ **`retry`**
67
+
68
+ - `times` — total attempts (default `3`).
69
+ - `delay` — number or `(attempt, error) => ms`.
70
+ - `shouldRetry(error, attempt)` — stop early.
71
+ - `fn` gets the attempt number starting at `1`.
72
+ - On abort, rejects with the abort reason.
73
+
74
+ **`timeout`**
75
+
76
+ - Error is a `DOMException` named `TimeoutError` (like `AbortSignal.timeout`).
77
+ - Rejects even if the starter ignores its signal.
78
+
79
+ **`poll`** (like patronum `interval`)
80
+
81
+ - First call after `ms`. `leading: true` also calls at once.
82
+ - Next ticks keep a steady rate whatever the duration of `fn`, so a slow `fn` can overlap the next call.
83
+ - `waitForFn: true` starts each pause when the call ends — calls never overlap.
84
+ - `fn` gets the call number from `0`.
85
+ - An error from `fn` stops polling and rejects. An abort ends it normally and clears the timer.
86
+
87
+ A pause can be a function wherever a time is given (backoff or jitter):
88
+
89
+ ```js
90
+ retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, …
91
+ poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows to once a minute
92
+ await delay(ctx, () => 200 + Math.random() * 200); // 200–400 ms
93
+ ```
94
+
95
+ All three are traced: the effect is a group (`retry`, `timeout`, `poll`) and each attempt or call is a `run` child.
96
+
97
+ ## Tie a promise to the task signal
98
+
99
+ `delay(ctx, ms)` waits and rejects on abort (`ms` can be a function).
100
+
101
+ `cancellable(ctx, executor)` is a promise tied to the task signal. It works like `new Promise`, but the executor gets `{ resolve, reject, onCancel }`.
102
+
103
+ ```js
104
+ import { createBus } from "nanoeffects";
8
105
  import { cancellable } from "nanoeffects/effects";
106
+ import { latest } from "nanoeffects/policies";
9
107
 
108
+ const bus = createBus();
109
+
110
+ // cancellable — promise tied to the task signal; onCancel for cleanups
111
+ // latest — a newer wait aborts the older timer
10
112
  bus.on(
11
113
  "wait",
12
- latest((_, ctx) =>
13
- cancellable(ctx, ({ resolve, onCancel }) => {
114
+ latest((_, ctx) => {
115
+ return cancellable(ctx, ({ resolve, onCancel }) => {
14
116
  const timer = setTimeout(resolve, 5000);
15
117
  onCancel(() => clearTimeout(timer));
16
- }),
17
- ),
118
+ });
119
+ }),
18
120
  );
19
121
  ```
20
122
 
@@ -22,116 +124,93 @@ bus.on(
22
124
  - If the task is already aborted, the executor does not run.
23
125
  - After the promise settles, the abort listener is removed and cleanups are dropped.
24
126
 
25
- `onAbort(ctx, fn)` runs `fn(reason)` on abort (at once if already aborted) and returns a function that unsubscribes:
127
+ ## Run cleanup on abort
128
+
129
+ `onAbort(ctx, fn)` runs `fn(reason)` on abort (at once if already aborted) and returns a function that unsubscribes.
26
130
 
27
- ```ts
131
+ ```js
132
+ import { createBus } from "nanoeffects";
28
133
  import { onAbort } from "nanoeffects/effects";
29
134
 
135
+ const bus = createBus();
136
+
30
137
  bus.on("connect", (url, ctx) => {
31
138
  const socket = new WebSocket(url);
139
+ // onAbort — run cleanup when this task aborts
32
140
  onAbort(ctx, () => socket.close());
33
141
  });
34
142
  ```
35
143
 
36
- ## 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.
144
+ ## Wait for the next event with `take`
39
145
 
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
- ```
146
+ A task context has `on` and `off`, like the bus. A subscription made with `ctx.on` ends when the task aborts:
48
147
 
49
- ## Subscribing from a task: ctx.on, ctx.off, take
148
+ ```js
149
+ import { createBus } from "nanoeffects";
50
150
 
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:
151
+ const bus = createBus();
52
152
 
53
- ```ts
54
153
  bus.on("watch", (_, ctx) => {
55
154
  ctx.on("tick", (n) => console.log(n)); // gone when this task aborts
56
155
  });
57
156
  ```
58
157
 
59
- - The returned function unsubscribes sooner. After 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.
158
+ - The returned function unsubscribes sooner. After abort, `ctx.on` does nothing.
159
+ - Under `latest`, subscriptions of a superseded run end with that run.
160
+ - `ctx.off(event)` clears every handler of the event on the bus, not only the task’s.
62
161
 
63
162
  `take(ctx, event, options?)` waits for the next `event` and resolves with its payload:
64
163
 
65
- ```ts
164
+ ```js
165
+ import { createBus } from "nanoeffects";
66
166
  import { take, timeout } from "nanoeffects/effects";
67
167
 
168
+ const bus = createBus();
169
+
68
170
  bus.on("login", async (user, ctx) => {
69
171
  const reason = await take(ctx, "logout"); // resolves on the next logout
70
172
  cleanup(user, reason);
71
173
 
72
174
  // wait for a matching event, but not longer than 5 s
73
175
  const id = await timeout(ctx, 5000, ({ signal }) =>
74
- take(ctx, "saved", { signal, predicate: (id) => id === draft.id }),
176
+ take(ctx, "saved", { signal, predicate: (savedId) => savedId === draft.id }),
75
177
  );
76
178
  });
77
179
  ```
78
180
 
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.
181
+ - Sees only events emitted after the call, takes one, and unsubscribes.
182
+ - `options.predicate` skips payloads it rejects.
183
+ - `options.signal` stops the wait (how a `race` or `timeout` starter cancels it).
184
+ - On abort of the task or of `options.signal`, unsubscribes and rejects with the abort reason.
82
185
 
83
- ## retry, timeout, poll
186
+ ## Run several starters together
84
187
 
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.
188
+ `all(ctx, effects)` runs starters together. One failure cancels the rest. Shape of the input (object or array) is preserved in the result.
86
189
 
87
- ```ts
88
- import { poll, retry, timeout } from "nanoeffects/effects";
190
+ `allSettled(ctx, effects)` waits for every starter and never fails because of one. Each result is `{ status: "fulfilled", value }` or `{ status: "rejected", reason }`. Nothing is cancelled when a starter fails. Only an abort of `ctx` rejects it.
89
191
 
90
- const loadUser = (id: string, { signal }) => fetchUser(id, { signal });
192
+ ```js
193
+ import { createBus } from "nanoeffects";
194
+ import { allSettled } from "nanoeffects/effects";
91
195
 
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
- );
196
+ const bus = createBus();
103
197
 
104
- // calls refresh every 10 s until the task is aborted
105
- bus.on("watch", poll(10_000, (_, { signal }) => refresh({ signal })));
106
-
107
- // a wrapped Task runs as a child of a handler with ctx.run: the only place where ctx is written
108
198
  bus.on("open", async (id, ctx) => {
109
- const user = await ctx.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);
199
+ const { user, posts } = await allSettled(ctx, {
200
+ user: ({ signal }) => fetchUser(id, { signal }),
201
+ posts: ({ signal }) => fetchPosts(id, { signal }),
202
+ });
203
+
204
+ if (user.status === "fulfilled") {
205
+ render(user.value);
206
+ }
117
207
  });
118
208
  ```
119
209
 
120
- 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
- ```
210
+ `race(ctx, effects)` settles with the first starter that finishes and cancels the others.
132
211
 
133
- - All three are traced: the effect is a group (`retry`, `timeout`, `poll`) and each attempt or call is a `run` child.
212
+ ## Pass `ctx` outside a task
134
213
 
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`.
214
+ Effects that wait inside a body take `ctx` first: `delay`, `all`, `allSettled`, `race`, `cancellable`, `onAbort`, `take`, and the `ctx` forms of `retry` / `timeout` / `poll`. Outside a task, pass `{}` or any object with a `signal`.
136
215
 
137
216
  For `fetch` and other APIs that take a signal, pass `ctx.signal`. A timeout without `race`: `AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)])`.