nanoeffects 0.0.0-stage → 0.1.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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -2
  3. package/dist/action-BM-UdmJr.d.ts +302 -0
  4. package/dist/actions.d.ts +196 -0
  5. package/dist/actions.js +1 -0
  6. package/dist/bus-RUiw8f9p.js +1 -0
  7. package/dist/context-BXQ6Einm.js +1 -0
  8. package/dist/delay-CIHnOh_L.js +1 -0
  9. package/dist/effects.d.ts +244 -0
  10. package/dist/effects.js +1 -0
  11. package/dist/event-0RIBGYqS.js +1 -0
  12. package/dist/index-CaxrMD1A.d.ts +1 -0
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.js +1 -0
  15. package/dist/notrace/action-BM-UdmJr.d.ts +302 -0
  16. package/dist/notrace/actions.d.ts +196 -0
  17. package/dist/notrace/actions.js +1 -0
  18. package/dist/notrace/bus-CFxgpsRL.js +1 -0
  19. package/dist/notrace/context-DGPIFXxI.js +1 -0
  20. package/dist/notrace/delay-Cko_Q9hl.js +1 -0
  21. package/dist/notrace/effects.d.ts +244 -0
  22. package/dist/notrace/effects.js +1 -0
  23. package/dist/notrace/event-0RIBGYqS.js +1 -0
  24. package/dist/notrace/index-CaxrMD1A.d.ts +1 -0
  25. package/dist/notrace/index.d.ts +3 -0
  26. package/dist/notrace/index.js +1 -0
  27. package/dist/notrace/policies.d.ts +171 -0
  28. package/dist/notrace/policies.js +1 -0
  29. package/dist/notrace/promise-CSLpzwSR.js +1 -0
  30. package/dist/notrace/redux.d.ts +92 -0
  31. package/dist/notrace/redux.js +1 -0
  32. package/dist/observable.d.ts +20 -0
  33. package/dist/observable.js +1 -0
  34. package/dist/onAbort-BgMfspK4.js +1 -0
  35. package/dist/policies.d.ts +171 -0
  36. package/dist/policies.js +1 -0
  37. package/dist/promise-CSLpzwSR.js +1 -0
  38. package/dist/redux.d.ts +92 -0
  39. package/dist/redux.js +1 -0
  40. package/dist/trace.d.ts +144 -0
  41. package/dist/trace.js +1 -0
  42. package/docs/README.md +12 -0
  43. package/docs/actions.md +68 -0
  44. package/docs/context.md +16 -0
  45. package/docs/effects.md +119 -0
  46. package/docs/errors.md +23 -0
  47. package/docs/hmr.md +74 -0
  48. package/docs/observable.md +22 -0
  49. package/docs/policies.md +53 -0
  50. package/docs/semantics.md +7 -0
  51. package/docs/stores.md +50 -0
  52. package/docs/tracing.md +104 -0
  53. package/package.json +164 -5
@@ -0,0 +1,119 @@
1
+ # Effects and cancellation
2
+
3
+ An abort only raises a flag: your code decides to stop. `nanoeffects/effects` has helpers for the usual cases.
4
+
5
+ `delay(ctx, ms)` waits and rejects on abort (`ms` can be a function). `cancellable(ctx, executor)` is a promise tied to the task signal; it works like `new Promise`, but the executor gets one object, `{ resolve, reject, onCancel }`, with `onCancel` for cleanups:
6
+
7
+ ```ts
8
+ import { cancellable } from "nanoeffects/effects";
9
+
10
+ bus.on(
11
+ "wait",
12
+ latest((_, ctx) =>
13
+ cancellable(ctx, ({ resolve, onCancel }) => {
14
+ const timer = setTimeout(resolve, 5000);
15
+ onCancel(() => clearTimeout(timer));
16
+ }),
17
+ ),
18
+ );
19
+ ```
20
+
21
+ - If the task aborts before the promise settles, cleanups run (last registered first) and the promise rejects with the abort reason.
22
+ - If the task is already aborted, the executor does not run.
23
+ - After the promise settles, the abort listener is removed and cleanups are dropped.
24
+
25
+ `onAbort(ctx, fn)` runs `fn(reason)` on abort (at once if already aborted) and returns a function that unsubscribes:
26
+
27
+ ```ts
28
+ import { onAbort } from "nanoeffects/effects";
29
+
30
+ bus.on("connect", (url, ctx) => {
31
+ const socket = new WebSocket(url);
32
+ onAbort(ctx, () => socket.close());
33
+ });
34
+ ```
35
+
36
+ ## allSettled
37
+
38
+ `allSettled(ctx, effects)` is `all` that waits for every starter and never fails because of one: each result is `{ status: "fulfilled", value }` or `{ status: "rejected", reason }`, in the shape of the input (object or array). Nothing is cancelled when a starter fails. Only an abort of `ctx` rejects it.
39
+
40
+ ```ts
41
+ const { user, posts } = await allSettled(ctx, {
42
+ user: ({ signal }) => fetchUser(id, { signal }),
43
+ posts: ({ signal }) => fetchPosts(id, { signal }),
44
+ });
45
+
46
+ if (user.status === "fulfilled") render(user.value);
47
+ ```
48
+
49
+ ## Subscribing from a task: ctx.on, ctx.off, take
50
+
51
+ A task context has `on` and `off`, like the bus. A subscription made with `ctx.on` ends when the task aborts, so it cannot outlive the work that needed it:
52
+
53
+ ```ts
54
+ bus.on("watch", (_, ctx) => {
55
+ ctx.on("tick", (n) => console.log(n)); // gone when this task aborts
56
+ });
57
+ ```
58
+
59
+ - The returned function unsubscribes sooner. After the task is aborted, `ctx.on` does nothing.
60
+ - A policy passes its handler a child context, so under `latest` the subscriptions of a superseded run end with it.
61
+ - `ctx.off` is the bus `off`: `ctx.off(event)` clears every handler of the event, not only the task's.
62
+
63
+ `take(ctx, event, options?)` waits for the next `event` and resolves with its payload:
64
+
65
+ ```ts
66
+ import { take, timeout } from "nanoeffects/effects";
67
+
68
+ bus.on("login", async (user, ctx) => {
69
+ const reason = await take(ctx, "logout"); // resolves on the next logout
70
+ cleanup(user, reason);
71
+
72
+ // wait for a matching event, but not longer than 5 s
73
+ const id = await timeout(ctx, 5000, ({ signal }) =>
74
+ take(ctx, "saved", { signal, predicate: (id) => id === draft.id }),
75
+ );
76
+ });
77
+ ```
78
+
79
+ - It sees only events emitted after the call, takes one, and unsubscribes.
80
+ - `options.predicate` skips payloads it rejects; `options.signal` stops the wait, which is how a `race` or `timeout` starter cancels it.
81
+ - On abort of the task or of `options.signal` it unsubscribes and rejects with the abort reason. It is traced as a `take` group.
82
+
83
+ ## retry, timeout, poll
84
+
85
+ ```ts
86
+ import { poll, retry, timeout } from "nanoeffects/effects";
87
+
88
+ bus.on("load", async (id, ctx) => {
89
+ // up to 4 attempts, backoff between them; stops when the task aborts
90
+ const user = await retry(ctx, () => fetchUser(id, { signal: ctx.signal }), {
91
+ times: 4,
92
+ delay: (attempt) => 2 ** attempt * 100,
93
+ shouldRetry: (error) => !isFatal(error),
94
+ });
95
+
96
+ // aborts the starter's signal and rejects with a TimeoutError after 5 s
97
+ const data = await timeout(ctx, 5000, ({ signal }) => fetchData(id, { signal }));
98
+
99
+ // calls refresh every 10 s; resolves when the task aborts
100
+ await poll(ctx, () => refresh(), 10_000);
101
+ });
102
+ ```
103
+
104
+ - `retry`: `times` is the total number of attempts (default 3), `delay` is a number or `(attempt, error) => ms`, `shouldRetry(error, attempt)` can stop early. `fn` gets the attempt number, starting at 1. On abort it rejects with the abort reason.
105
+ - `timeout`: the error is a `DOMException` named `TimeoutError`, like `AbortSignal.timeout`. It rejects even if the starter ignores its signal.
106
+ - `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.
107
+ - 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:
108
+
109
+ ```ts
110
+ retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, ...
111
+ poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows down to once a minute
112
+ await delay(ctx, () => 200 + Math.random() * 200); // 200 to 400 ms
113
+ ```
114
+
115
+ - All three are traced: the effect is a group (`retry`, `timeout`, `poll`) and each attempt or call is a `run` child.
116
+
117
+ In every effect `ctx` is the first argument: `delay(ctx, ms)`, `all(ctx, effects)`, `allSettled(ctx, effects)`, `race(ctx, effects)`, `cancellable(ctx, executor)`, `onAbort(ctx, fn)`, `retry(ctx, fn, options)`, `timeout(ctx, ms, starter)`, `poll(ctx, fn, ms, options)`, `take(ctx, event, options)`. It is required; outside a task pass `{}` or any object with a `signal`.
118
+
119
+ For `fetch` and other APIs that take a signal, pass `ctx.signal`. A timeout without `race`: `AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)])`.
package/docs/errors.md ADDED
@@ -0,0 +1,23 @@
1
+ # Errors and unheard events
2
+
3
+ `emit` is a plain loop over the handlers, like in nanoevents: if a handler throws, the remaining handlers are skipped and the error reaches the caller of `emit`. A rejected promise of an async handler is left unhandled.
4
+
5
+ Pass `onError` to isolate handlers instead. Then every handler runs, and thrown errors and rejections of async handlers (including ones wrapped in `latest`, `debounce` and the like) go to `onError`; an `onError` that throws is logged to `console.error`.
6
+
7
+ ```ts
8
+ const bus = createBus<Events>({
9
+ onError: (error, { type, payload }) => report(error, type, payload),
10
+ });
11
+ ```
12
+
13
+ ## Events that nobody listens to
14
+
15
+ A typo in an event name, `emit("serch")`, does nothing and says nothing. `onUnhandled` is called when an event is emitted and nobody listens to it (a listener of every event, `"*"`, counts). It is not called for commands, which always have a handler to run. Use it as a warning in development:
16
+
17
+ ```ts
18
+ const bus = createBus<Events>({
19
+ onUnhandled: import.meta.env.DEV
20
+ ? ({ type }) => console.warn(`Nobody listens to "${type}"`)
21
+ : void 0,
22
+ });
23
+ ```
package/docs/hmr.md ADDED
@@ -0,0 +1,74 @@
1
+ # Hot reload (HMR)
2
+
3
+ When a file is edited in development, the bundler loads its new version and runs it again, while the page and the rest of the code stay alive. For a bus that means three things can go wrong:
4
+
5
+ 1. **Handlers pile up.** The file that calls `bus.on(...)` runs again, and the old handlers are still subscribed: every event is handled twice, then three times.
6
+ 2. **The bus is made again.** A file that has `createBus()` runs again: the new bus has none of the subscriptions of the files that did not reload.
7
+ 3. **Old work goes on.** Tasks that the old code started keep running with the old logic.
8
+
9
+ Tasks that are tied to a signal can be ended, and modules are the unit that does it, so the answer is modules.
10
+
11
+ ## Modules are replaced by name
12
+
13
+ Mounting a module whose name is taken replaces the mounted one: the old module is unmounted first, and with it its subscriptions, its bound commands, its sub-modules and its background tasks (their signals abort). So the new version of a module file only has to mount again.
14
+
15
+ ```ts
16
+ // bus.ts: made once, kept across reloads
17
+ import { createBus } from "nanoeffects";
18
+ import { registerModules } from "nanoeffects/actions";
19
+
20
+ const hot = import.meta.hot;
21
+
22
+ export const bus: Bus<Events> = hot?.data.bus ?? createBus<Events>();
23
+ export const app: ModuleApi<Events> = hot?.data.app ?? registerModules(bus);
24
+ if (hot) {
25
+ hot.data.bus = bus;
26
+ hot.data.app = app;
27
+ }
28
+ ```
29
+
30
+ On the first run `hot.data` is empty and the bus and the app are made; on a reload the same ones are taken back. The same code is in `examples/hmr.ts`, which is type-checked with the rest of the project.
31
+
32
+ ```ts
33
+ // user.module.ts: the module, and nothing else
34
+ export const userModule = createModule<Events>("user", (m) => {
35
+ m.handle(getUser, fetchUser);
36
+ m.on("login", latest(loadProfile));
37
+ });
38
+ ```
39
+
40
+ ```ts
41
+ // main.ts: mounts the modules, and takes the new version of a module on reload
42
+ import { app } from "./bus";
43
+ import { userModule } from "./user.module";
44
+
45
+ app.mount(userModule);
46
+
47
+ // replaces the old "user" module with the new version
48
+ import.meta.hot?.accept("./user.module", (next) => next && app.mount(next.userModule));
49
+ ```
50
+
51
+ Vite calls the callback with the new version of `./user.module`, `main.ts` stays alive, and `app.mount` swaps the module. With webpack, `module.hot.accept("./user.module", () => app.mount(require("./user.module").userModule))` does the same.
52
+
53
+ ## What to keep in mind
54
+
55
+ - **Keep the bus and the app in `import.meta.hot.data`** (or in a file that never reloads), as above. Otherwise a reload of that file makes a new bus, and the modules that did not reload talk to the old one.
56
+ - **Names must be unique.** The name is what says "this is the new version of that module", so two different modules with one name replace each other. The name is also the type of the module's tasks in the trace.
57
+ - **Pass `signal` to what the module does.** An unmount aborts the tasks, but a `fetch` or a loop that ignores `ctx.signal` runs on. This is the same advice as everywhere else, and in development it also stops old code from writing to the new state.
58
+ - **Sub-modules** (`m.use(...)`) are replaced by name inside their parent. A reload of the parent module replaces it with all its children.
59
+
60
+ ## Handlers without modules
61
+
62
+ A plain `bus.on` returns the function that unsubscribes. Give it to the dispose hook of the file:
63
+
64
+ ```ts
65
+ if (import.meta.hot) {
66
+ import.meta.hot.dispose(bus.on("search", latest(runSearch)));
67
+ }
68
+ ```
69
+
70
+ Several: `import.meta.hot.dispose(() => { off1(); off2(); })`. Handlers inside modules do not need this.
71
+
72
+ ## Redux and stores
73
+
74
+ With `createEffectsMiddleware`, keep the effects object in `import.meta.hot.data` as well, so the store keeps the same middleware, and register the effects in modules. For a new reducer use `store.replaceReducer(...)` as usual. A store of Zustand or MobX that lives in its own file and is passed in `context` can keep its state across reloads with the means of that library.
@@ -0,0 +1,22 @@
1
+ # Observable: events as streams
2
+
3
+ `createObserve(bus)` from `nanoeffects/observable` turns an event of the bus into an Observable that follows the TC39 proposal (`subscribe`, `unsubscribe`, `Symbol.observable`). RxJS and other stream libraries take it as it is, and nothing here depends on them.
4
+
5
+ ```ts
6
+ import { from } from "rxjs";
7
+ import { debounceTime, distinctUntilChanged, switchMap } from "rxjs/operators";
8
+ import { createObserve } from "nanoeffects/observable";
9
+
10
+ const observe = createObserve(bus);
11
+
12
+ // events of the bus as a stream
13
+ from(observe("typed"))
14
+ .pipe(debounceTime(300), distinctUntilChanged(), switchMap(searchRequest))
15
+ .subscribe((items) => bus.emit("results", items));
16
+ ```
17
+
18
+ - `observe(event)` returns an Observable of the payloads of that event. Each `subscribe` is a `bus.on` of its own, and `unsubscribe()` removes it.
19
+ - `subscribe` takes a function or an object with `next`.
20
+ - The stream never completes and never fails: events are the only thing that arrives. Errors of the handler of the subscriber belong to the subscriber.
21
+ - A subscription made this way is not tied to a task, so end it yourself: `onAbort(ctx, subscription.unsubscribe)` inside a task, or with the signal of a module.
22
+ - For cancellation, policies and tracing keep to `bus.on` with `latest`, `debounce`, `queue`: they know about tasks. The Observable is for the places where a stream is already the way you work.
@@ -0,0 +1,53 @@
1
+ # Policies
2
+
3
+ A policy wraps a handler and decides what happens when calls overlap. Every policy takes `key` in its options (see below), so each key has its own state.
4
+
5
+ | Policy | Overlapping calls |
6
+ | ------------------------------ | -------------------------------------------------------------------------- |
7
+ | `latest(fn)` | the previous run is aborted and its result dropped |
8
+ | `leading(fn)` | new calls are dropped while a run is going |
9
+ | `debounce(ms, fn, options?)` | run after `ms` of quiet |
10
+ | `throttle(ms, fn, options?)` | at most one run per `ms` |
11
+ | `queue(fn, options?)` | one after another, in order |
12
+
13
+ `debounce` and `throttle` take `leading` and `trailing`:
14
+
15
+ - `throttle(ms, fn)` works like lodash `throttle` (and patronum): a call made when the throttle is idle, at least `ms` after the previous call and the previous run, runs at once (`leading`, default `true`); calls made while it waits are held, and the latest of them runs when the window closes (`trailing`, default `true`). Under a steady stream of calls it runs every `ms`. `leading: false` makes even the first call wait for the end of its window; `trailing: false` drops the calls made during a window.
16
+ - `debounce(ms, fn)` runs the last call after `ms` of quiet (`trailing`, default `true`). `leading: true` also runs the first call of a burst at once, and then the trailing run happens only if more calls follow it. `leading: true, trailing: false` runs only the first call of a burst.
17
+ - With both options `false` nothing runs.
18
+ - `throttle` and `debounce` are checked against lodash itself: the tests feed random sequences of calls to both and compare the moments `fn` runs.
19
+
20
+ `key` gives each key its own state, so calls with different keys do not affect each other:
21
+
22
+ ```ts
23
+ // one latest run per user instead of one for all of them
24
+ bus.on("load", latest(loadUser, { key: (user) => user.id }));
25
+
26
+ // uploads of the same file go one after another, different files in parallel
27
+ bus.on("upload", queue(upload, { key: (file) => file.name }));
28
+ ```
29
+
30
+ The state of a key is dropped when that key goes idle, so keys can be unbounded (ids, urls).
31
+
32
+ ## Queue
33
+
34
+ `queue(fn, options?)` runs the calls of a handler one after another, in the order they arrived, instead of dropping or replacing them like `leading` and `latest` do:
35
+
36
+ ```ts
37
+ import { queue } from "nanoeffects/policies";
38
+
39
+ bus.on(
40
+ "save",
41
+ queue(async (draft, { signal }) => {
42
+ await api.save(draft, { signal });
43
+ }),
44
+ );
45
+
46
+ bus.emit("save", a); // runs now
47
+ bus.emit("save", b); // waits for a, then runs
48
+ ```
49
+
50
+ - `concurrency` (default 1) is how many calls may run at the same time.
51
+ - `limit` caps how many calls may wait, and `overflow` says what to drop when the line is full: the new call (`"drop-new"`, the default) or the oldest waiting one (`"drop-old"`). A dropped call resolves with `undefined`. `queue(fn, { limit: 1, overflow: "drop-old" })` keeps only the latest call while one is running and runs it when the current one is done; `limit: 0` lets nothing wait.
52
+ - Every call returns a promise with the result of its own run. An error rejects only that call and the queue goes on.
53
+ - A waiting call whose task aborts is skipped and resolves with `undefined`. When the task of a running call aborts, its slot is freed at once and its result is dropped, so pass `signal` to what `fn` does: a `fn` that ignores it can overlap with the next run.
@@ -0,0 +1,7 @@
1
+ # Semantics
2
+
3
+ - **An aborted task delivers no result.** If the task aborts before a promise settles, the promise rejects with the abort reason, even if the value was just produced in the same tick. `latest`, `debounce`, `all`, `race`, `take` and the rest follow this rule.
4
+ - **`emit` is a plain, synchronous loop** (see "Errors in handlers"). A `ctx.emit` inside a handler is the same call: without `onError` an error of a nested handler is thrown into the handler that emitted; with `onError` it goes there and `ctx.emit` does not throw.
5
+ - **Rejections of async handlers** are not handled for you. Without `onError` they are unhandled rejections, which end a Node process by default. Use `onError` for async handlers.
6
+ - **`leading` releases its lock when the task aborts**, not only when `fn` settles. A `fn` that ignores the abort signal can then overlap with the next run, so pass `ctx.signal` to what `fn` does.
7
+ - **Effects start their function synchronously**, so an event emitted right after the call reaches a `take` inside it.
package/docs/stores.md ADDED
@@ -0,0 +1,50 @@
1
+ # Redux, Zustand, MobX
2
+
3
+ ## Redux
4
+
5
+ `createEffectsMiddleware()` from `nanoeffects/redux` gives you a bus and the Redux middleware that feeds it: the effects of an app in one place, without sagas. Every action goes to the bus after the reducers have handled it, so a handler that reads the store sees the new state, and every task gets `ctx.store` (`dispatch` and `getState`).
6
+
7
+ ```ts
8
+ type Events = { userRequested: string } & WithContext<{ store: ReduxStoreApi<RootState> }>;
9
+
10
+ const effects = createEffectsMiddleware<Events>();
11
+
12
+ const store = configureStore({
13
+ reducer,
14
+ middleware: (getDefault) => getDefault().concat(effects.middleware),
15
+ });
16
+
17
+ effects.bus.on(userRequested, latest(async (id, { store, signal }) => {
18
+ store.dispatch(userLoaded(await fetchUser(id, { signal })));
19
+ }));
20
+ ```
21
+
22
+ It looks after what is easy to get wrong:
23
+
24
+ - **A handler does not loop on its own action.** An action that a handler dispatches through `ctx.store` and that has the type the handler works on does not go back to the bus. The action is not marked or changed: the middleware remembers which task dispatched it. Other handlers of that type do not get it either. Longer cycles, `a` to `b` to `a`, are yours to avoid.
25
+ - **A failing handler does not break `dispatch`.** Errors go to `onError` (default `console.error`), as in Redux Toolkit listeners. Reducers still throw to the caller.
26
+ - **The store gets to the tasks by itself**, though the bus is made before the store: `ctx.store` appears when the middleware is added.
27
+
28
+ Options are those of `createBus` (except `context`, which adds fields next to `store`) and `filter`, which keeps the actions it rejects away from the bus. Values that are not plain actions (thunks) go through untouched. Nothing here depends on Redux.
29
+
30
+ To use your own bus, `nanoeffectsMiddleware(bus, { filter })` is the middleware alone: it sends actions to the bus after the reducers and nothing more. Give the store to the tasks yourself with `createBus({ context: { store } })`, or lazily, `context: { get store() { return store; } }` (typed with `WithContext`), since the store needs the middleware and the middleware needs the bus.
31
+
32
+ ## Zustand, MobX and other stores
33
+
34
+ No adapter is needed: a store tells about its changes, and `bus.emit` is a call.
35
+
36
+ ```ts
37
+ // Zustand
38
+ const stop = store.subscribe((state, previous) => {
39
+ if (state.user !== previous.user) bus.emit(userChanged, state.user);
40
+ });
41
+
42
+ // MobX
43
+ const stop = reaction(() => store.user, (user) => bus.emit(userChanged, user));
44
+
45
+ bus.on(userChanged, latest((user, { signal }) => sync(user, { signal })));
46
+ ```
47
+
48
+ Stop watching with what the store returned: `onAbort(ctx, stop)` inside a task, or `m.signal.addEventListener("abort", stop)` in a module.
49
+
50
+ The other way, give the store to the tasks, `createBus({ context: { store } })`, and change it from a handler: `setState` for Zustand, an action of the MobX store (it wraps `runInAction`). A handler that changes the value it was started by emits the event again, so watch the smallest part of the state. For a MobX `flow`, `onAbort(ctx, () => promise.cancel())` cancels it with the task.
@@ -0,0 +1,104 @@
1
+ # Tracing
2
+
3
+ ## Tracing
4
+
5
+ Pass `trace` to `createBus` to observe every task phase. Make it with `createTracer` from `nanoeffects/trace`, which takes a logger (a function) or an array of them. The tracing code lives in that entry, so a bus without `trace` does not carry it.
6
+
7
+ ```ts
8
+ import { createBus } from "nanoeffects";
9
+ import { createTracer } from "nanoeffects/trace";
10
+
11
+ const bus = createBus<Events>({
12
+ trace: createTracer((event) => console.log(event)),
13
+ });
14
+ ```
15
+
16
+ Each `TraceEvent` has:
17
+
18
+ | Field | Meaning |
19
+ | --------- | -------------------------------------------------------------------- |
20
+ | `id` | Unique task id within the bus. |
21
+ | `parent` | Id of the task that started this one (absent for handler tasks). |
22
+ | `type` | Event type the task works on. |
23
+ | `effect` | `latest`, `debounce`, `throttle`, `leading`, `run`, `all` or `race`. |
24
+ | `phase` | `start`, `done`, `error`, `cancel` or `drop`. |
25
+ | `payload` | Event payload. |
26
+ | `error` | Thrown value, only for `error`. |
27
+
28
+ Phases:
29
+
30
+ - `start`: the work began.
31
+ - `done` / `error`: it finished or threw.
32
+ - `cancel`: it was aborted after it started (superseded by `latest`, parent aborted, lost a `race`).
33
+ - `drop`: it never started (`leading` while busy, a `throttle` call replaced by a newer one, a `debounce` call replaced before its delay ended).
34
+
35
+ `all` and `race` are traced when you pass them the handler `ctx`: the group is one task, every starter is a child `run` task.
36
+
37
+ ```ts
38
+ bus.on("load", async (id, ctx) => {
39
+ await all(ctx, { user: () => fetchUser(id), posts: () => fetchPosts(id) });
40
+ });
41
+ ```
42
+
43
+ ## Loggers
44
+
45
+ A logger is a function `(event: TraceEvent) => void` (the `TraceLogger` type). `createTracer` takes one logger or an array; a logger that throws is reported to `console.error` and never breaks the task or the other loggers.
46
+
47
+ ```ts
48
+ import { createBus } from "nanoeffects";
49
+ import { consoleLogger, createTracer, reduxDevtools } from "nanoeffects/trace";
50
+
51
+ const bus = createBus<Events>({
52
+ trace: createTracer([reduxDevtools({ name: "app" }), consoleLogger()]),
53
+ });
54
+ ```
55
+
56
+ `nanoeffects/trace` ships `createTracer` and these loggers, each a factory that returns a `TraceLogger`:
57
+
58
+ | Logger | What it does |
59
+ | ----------------------- | ------------------------------------------------------------------------------------ |
60
+ | `reduxDevtools()` | Sends each phase to Redux DevTools as `type/effect/phase`; state is `{ running }`. |
61
+ | `consoleLogger()` | Prints one line per phase, indented by task depth. |
62
+ | `performanceLogger()` | Records User Timing measures for the Timings track of the browser Performance panel. |
63
+ | `createTraceRecorder()` | Keeps events in `recorder.events`, for tests. |
64
+
65
+ ### Production: filter and sampling
66
+
67
+ `createTracer(loggers, options)` can cut the volume:
68
+
69
+ ```ts
70
+ createTracer(logger, {
71
+ sample: 0.05, // trace 5% of the handler calls (a share, or a function of { type, payload })
72
+ filter: (event) => event.phase === "error", // and keep only failures
73
+ });
74
+ ```
75
+
76
+ - `sample` is decided once per root task (one handler call) and every task below it follows, so a trace is never cut in the middle. Tasks that are not sampled cost almost nothing: their effects run without tracing.
77
+ - `filter` runs on each event before any logger sees it.
78
+
79
+ Notes on `reduxDevtools`: it is read-only (the bus owns no state, so there is no time travel) and does nothing when the extension is not installed.
80
+
81
+ To write your own, return a `TraceLogger`:
82
+
83
+ ```ts
84
+ import type { TraceLogger } from "nanoeffects";
85
+
86
+ const errorsOnly = (): TraceLogger => (event) => {
87
+ if (event.phase === "error") report(event.error);
88
+ };
89
+ ```
90
+
91
+ ## Removing tracing from a build
92
+
93
+ Without a tracer the hooks cost almost nothing (a lookup that finds no tracer), and `nanoeffects/trace` is a separate entry that is only bundled when imported. To also drop the tracing code from the core, use the `notrace` export condition: the package ships a second build of `index`, `effects`, `policies` and `actions` made with `__NANOEFFECTS_TRACE__` set to `false`, so the bundler removes the tracer calls.
94
+
95
+ ```ts
96
+ // vite / rolldown
97
+ resolve: { conditions: ["notrace"] }
98
+ // webpack
99
+ resolve: { conditionNames: ["notrace", "import", "..."] }
100
+ // esbuild
101
+ conditions: ["notrace"]
102
+ ```
103
+
104
+ In a build of your own from the sources, `define: { __NANOEFFECTS_TRACE__: "false" }` does the same. With the condition set, the types of that build have no `trace` option: `createBus({ trace })` is a type error, so tracing cannot be left on by mistake (`scripts/notrace-types.mjs` makes `BusOptions.trace` `never` there). `nanoeffects/trace` has no `notrace` build. The saving is about 0.4 kB gzip of the whole library.
package/package.json CHANGED
@@ -1,6 +1,165 @@
1
1
  {
2
- "name": "nanoeffects",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "nanoeffects",
3
+ "version": "0.1.0",
4
+ "description": "Typed event bus where every handler is a task with an AbortSignal, plus policies (latest, debounce, throttle, queue), effects (retry, timeout, poll) and tracing. About 1.2 kB gzip.",
5
+ "keywords": [
6
+ "event-bus",
7
+ "event-emitter",
8
+ "effects",
9
+ "abort-signal",
10
+ "abortcontroller",
11
+ "debounce",
12
+ "throttle",
13
+ "queue",
14
+ "retry",
15
+ "side-effects",
16
+ "typescript"
17
+ ],
18
+ "license": "MIT",
19
+ "author": "Vladimir Yazykov <neizerth@gmail.com>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/nanoeffects/nanoeffects.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/nanoeffects/nanoeffects/issues"
26
+ },
27
+ "homepage": "https://github.com/nanoeffects/nanoeffects#readme",
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "type": "module",
32
+ "packageManager": "pnpm@11.13.0",
33
+ "sideEffects": false,
34
+ "files": [
35
+ "dist",
36
+ "README.md",
37
+ "LICENSE",
38
+ "docs"
39
+ ],
40
+ "module": "./dist/index.js",
41
+ "types": "./dist/index.d.ts",
42
+ "exports": {
43
+ ".": {
44
+ "notrace": {
45
+ "types": "./dist/notrace/index.d.ts",
46
+ "default": "./dist/notrace/index.js"
47
+ },
48
+ "types": "./dist/index.d.ts",
49
+ "import": "./dist/index.js"
50
+ },
51
+ "./effects": {
52
+ "notrace": {
53
+ "types": "./dist/notrace/effects.d.ts",
54
+ "default": "./dist/notrace/effects.js"
55
+ },
56
+ "types": "./dist/effects.d.ts",
57
+ "import": "./dist/effects.js"
58
+ },
59
+ "./policies": {
60
+ "notrace": {
61
+ "types": "./dist/notrace/policies.d.ts",
62
+ "default": "./dist/notrace/policies.js"
63
+ },
64
+ "types": "./dist/policies.d.ts",
65
+ "import": "./dist/policies.js"
66
+ },
67
+ "./actions": {
68
+ "notrace": {
69
+ "types": "./dist/notrace/actions.d.ts",
70
+ "default": "./dist/notrace/actions.js"
71
+ },
72
+ "types": "./dist/actions.d.ts",
73
+ "import": "./dist/actions.js"
74
+ },
75
+ "./trace": {
76
+ "types": "./dist/trace.d.ts",
77
+ "import": "./dist/trace.js"
78
+ },
79
+ "./redux": {
80
+ "notrace": {
81
+ "types": "./dist/notrace/redux.d.ts",
82
+ "default": "./dist/notrace/redux.js"
83
+ },
84
+ "types": "./dist/redux.d.ts",
85
+ "import": "./dist/redux.js"
86
+ },
87
+ "./observable": {
88
+ "types": "./dist/observable.d.ts",
89
+ "import": "./dist/observable.js"
90
+ }
91
+ },
92
+ "scripts": {
93
+ "build": "tsdown && node scripts/notrace-types.mjs",
94
+ "prepublishOnly": "pnpm build",
95
+ "check": "biome check ./src ./examples ./integration",
96
+ "format": "npx @biomejs/biome check --write --unsafe ./src ./examples ./integration",
97
+ "tsgo": "tsc --noEmit",
98
+ "test": "vitest run",
99
+ "test:watch": "vitest",
100
+ "prepare": "lefthook install",
101
+ "lint:package": "publint && attw --pack . --profile esm-only",
102
+ "size": "size-limit",
103
+ "verify": "pnpm tsgo && pnpm check && pnpm test && pnpm build && pnpm test:integration && pnpm lint:package && pnpm size",
104
+ "test:integration": "vitest run --config vitest.integration.config.ts"
105
+ },
106
+ "devDependencies": {
107
+ "@arethetypeswrong/cli": "^0.18.5",
108
+ "@biomejs/biome": "2.5.15",
109
+ "@commitlint/cli": "21.2.3",
110
+ "@commitlint/config-conventional": "21.2.3",
111
+ "@reduxjs/toolkit": "^2.13.0",
112
+ "@size-limit/esbuild": "^14.2.0",
113
+ "@size-limit/file": "^14.2.0",
114
+ "jotai": "^3.0.1",
115
+ "lefthook": "2.2.1",
116
+ "lodash": "4.17.21",
117
+ "mobx": "^7.0.6",
118
+ "publint": "^0.3.25",
119
+ "redux": "^5.0.1",
120
+ "size-limit": "^14.2.0",
121
+ "tsdown": "0.23.1",
122
+ "typescript": "7.0.2",
123
+ "unplugin-unused": "^0.6.0",
124
+ "vite": "^8.3.4",
125
+ "vitest": "5.0.5",
126
+ "webpack": "^5.112.0",
127
+ "zustand": "^5.0.15"
128
+ },
129
+ "publishConfig": {
130
+ "access": "public"
131
+ },
132
+ "size-limit": [
133
+ {
134
+ "name": "createBus",
135
+ "path": "dist/index.js",
136
+ "import": "{ createBus }",
137
+ "limit": "1.2 kB"
138
+ },
139
+ {
140
+ "name": "policies (all)",
141
+ "path": "dist/policies.js",
142
+ "limit": "2.6 kB"
143
+ },
144
+ {
145
+ "name": "effects (all)",
146
+ "path": "dist/effects.js",
147
+ "limit": "2.3 kB"
148
+ },
149
+ {
150
+ "name": "actions (all)",
151
+ "path": "dist/actions.js",
152
+ "limit": "1.8 kB"
153
+ },
154
+ {
155
+ "name": "redux",
156
+ "path": "dist/redux.js",
157
+ "limit": "1.5 kB"
158
+ },
159
+ {
160
+ "name": "observable",
161
+ "path": "dist/observable.js",
162
+ "limit": "0.5 kB"
163
+ }
164
+ ]
165
+ }