nanoeffects 0.21.0 → 0.23.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 (48) hide show
  1. package/dist/actions.d.ts +2 -2
  2. package/dist/actions.js +1 -1
  3. package/dist/{bus-duCCrqrr.js → bus-hHp-T7JV.js} +1 -1
  4. package/dist/context-UcmrAIhP.js +1 -0
  5. package/dist/{notrace/delay-BosJKFjg.js → delay-mxZkTg17.js} +1 -1
  6. package/dist/dev.d.ts +1 -1
  7. package/dist/effects.d.ts +4 -4
  8. package/dist/effects.js +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/{module-B0KVDNjY.d.ts → module-D1VVKbE0.d.ts} +1 -1
  11. package/dist/notrace/actions.d.ts +1 -1
  12. package/dist/notrace/actions.js +1 -1
  13. package/dist/notrace/{bus-BkUtjPHp.js → bus-BdyvumpX.js} +1 -1
  14. package/dist/notrace/context-_HWcdjZf.js +1 -0
  15. package/dist/{delay-BnBdGPh7.js → notrace/delay-CqKplsaY.js} +1 -1
  16. package/dist/notrace/effects.d.ts +4 -4
  17. package/dist/notrace/effects.js +1 -1
  18. package/dist/notrace/index.js +1 -1
  19. package/dist/notrace/{plugin-C6KA4dX-.d.ts → plugin-BEvYzo8Q.d.ts} +52 -29
  20. package/dist/notrace/policies.js +1 -1
  21. package/dist/oop.js +1 -1
  22. package/dist/{plugin-D9UTzGRG.d.ts → plugin-BZK83Z3N.d.ts} +52 -29
  23. package/dist/policies.js +1 -1
  24. package/dist/react.js +1 -1
  25. package/dist/scope.d.ts +1 -1
  26. package/dist/scope.js +1 -1
  27. package/dist/signal.js +1 -1
  28. package/dist/svelte.d.ts +46 -0
  29. package/dist/svelte.js +1 -0
  30. package/dist/trace.js +1 -1
  31. package/dist/{types-CbdJKC2z.d.ts → types-CBoV9m7W.d.ts} +1 -1
  32. package/dist/{with-policy-CW50Edgk.js → with-policy-D-SQRkPh.js} +1 -1
  33. package/docs/README.md +3 -0
  34. package/docs/actions.md +1 -1
  35. package/docs/chat-example.md +33 -27
  36. package/docs/effects.md +36 -5
  37. package/docs/migrating-from-rxjs.md +2 -2
  38. package/docs/migrating-from-saga.md +3 -1
  39. package/docs/package.md +1 -0
  40. package/docs/server.md +205 -0
  41. package/docs/starter.md +3 -3
  42. package/docs/stores.md +1 -1
  43. package/docs/svelte.md +81 -0
  44. package/llms-full.txt +375 -41
  45. package/llms.txt +4 -2
  46. package/package.json +24 -4
  47. package/dist/context-CJGW6OWj.js +0 -1
  48. package/dist/notrace/context-yYU3_9lq.js +0 -1
package/docs/package.md CHANGED
@@ -16,6 +16,7 @@ Imports are split so you only load what you use. `createBus` stays small; the re
16
16
  | `nanoeffects/redux` | `createEffectsMiddleware` |
17
17
  | `nanoeffects/observable` | `createObserve`, `Subject` |
18
18
  | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
19
+ | `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) |
19
20
  | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) |
20
21
  | `nanoeffects/dev` | `devPlugin` (no-op in production) |
21
22
  | `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) |
package/docs/server.md ADDED
@@ -0,0 +1,205 @@
1
+ # Stop work on a server when the client leaves
2
+
3
+ Read this page if you run a Node server (the examples use [Fastify](https://fastify.dev)) and want the work for a request, a socket or a queued job to stop when nobody needs it. It shows one pattern in three places: a request, a WebSocket and a worker.
4
+
5
+ You need Node 18 or newer, `fastify` and `nanoeffects`. `api` below is your own client for another service: replace it with your requests.
6
+
7
+ ## Why a server needs this
8
+
9
+ A browser tab that closes stops its own requests. A server does not: if a client leaves, the requests that the server made for it keep running, and so do their retries. On a busy server that is wasted work and a slower service for everyone else.
10
+
11
+ The fix is one `AbortController` for each unit of work that the client owns, and one rule: **when the client goes, abort the controller.** `signalPlugin` lets `bus.emit` take that signal, so `timeout`, `retry` and the request itself stop.
12
+
13
+ | Unit of work | Created when | Aborted when |
14
+ | ------------ | ------------ | ------------ |
15
+ | An HTTP request | the route starts | the connection closes before the answer |
16
+ | A WebSocket | the socket connects | the socket closes |
17
+ | A queued job | the job is accepted | the server shuts down |
18
+
19
+ ## Make the bus and the command
20
+
21
+ The bus and the command live in files that know nothing about HTTP, so the same command can run in a route, a test or a worker.
22
+
23
+ ```js
24
+ // bus.js
25
+ import { createBus } from "nanoeffects";
26
+ import { signalPlugin } from "nanoeffects/signal";
27
+
28
+ // createBus — the bus the server shares
29
+ // signalPlugin — lets emit take { signal }, so a closed connection can abort the work
30
+ export const bus = createBus().use(signalPlugin());
31
+ ```
32
+
33
+ The command 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
+
35
+ ```js
36
+ // user.js
37
+ import { createCommand } from "nanoeffects/actions";
38
+ import { retry, timeout } from "nanoeffects/effects";
39
+
40
+ // createCommand — a command with a handler: emit returns what the handler returns
41
+ export const getUser = createCommand("user/get", async (id, ctx) => {
42
+ // one try: the request, with the signal of timeout so it can stop it
43
+ const load = ({ signal }) => api.getUser(id, { signal });
44
+ // timeout — fail the try after 5 s
45
+ const attempt = () => timeout(ctx, 5000, load);
46
+ // retry — up to 3 tries, 500 ms apart
47
+ return retry(ctx, attempt, { times: 3, delay: 500 });
48
+ });
49
+ ```
50
+
51
+ ## Stop the work of a request
52
+
53
+ <!-- docs-check: continue -->
54
+
55
+ Make a controller when the route starts. Listen to `close` of the **response** (`reply.raw`): it ends when the connection ends. `request.raw` also emits `close`, but it does so when the request body has been read, not when the client leaves, so it is the wrong event here.
56
+
57
+ ```js
58
+ // server.js
59
+ import Fastify from "fastify";
60
+ import { bus } from "./bus";
61
+ import { getUser } from "./user";
62
+
63
+ export const app = Fastify();
64
+
65
+ // the controllers of the requests that are still being answered, for the shutdown below
66
+ export const inFlight = new Set();
67
+
68
+ app.get("/users/:id", async (request, reply) => {
69
+ const controller = new AbortController();
70
+ inFlight.add(controller);
71
+
72
+ // close on the response — the connection ended; if the answer was not finished, the client left
73
+ reply.raw.on("close", () => {
74
+ inFlight.delete(controller);
75
+ if (!reply.raw.writableFinished) {
76
+ controller.abort();
77
+ }
78
+ });
79
+
80
+ try {
81
+ // emit with { signal } — aborting the controller aborts the command: timeout, retry and the request
82
+ const call = getUser(request.params.id);
83
+ return await bus.emit(call, { signal: controller.signal });
84
+ } catch (error) {
85
+ // nobody is left to answer when the client closed the connection
86
+ if (reply.raw.destroyed) {
87
+ return reply;
88
+ }
89
+ return reply.code(502).send({ error: String(error) });
90
+ }
91
+ });
92
+ ```
93
+
94
+ After an abort the command rejects with the abort reason. The `catch` above ignores it when the connection is gone, because there is nobody to answer.
95
+
96
+ ## Stop the server
97
+
98
+ <!-- docs-check: continue -->
99
+
100
+ Keep the controllers of the requests in flight in a set. On SIGTERM (what a container sends) or SIGINT (Ctrl+C), abort them all, then close the app. A request that is aborted this way answers with an error, and `app.close()` waits for the connections to end.
101
+
102
+ ```js
103
+ // shutdown.js
104
+ import { app, inFlight } from "./server";
105
+
106
+ // SIGTERM is what a container sends, SIGINT is Ctrl+C
107
+ for (const name of ["SIGTERM", "SIGINT"]) {
108
+ process.on(name, async () => {
109
+ for (const controller of inFlight) {
110
+ controller.abort();
111
+ }
112
+ await app.close();
113
+ process.exit(0);
114
+ });
115
+ }
116
+ ```
117
+
118
+ The route above fills `inFlight` and empties it in the `close` listener. The [api-server example](../examples/api-server) does the same, with a limit of two seconds for the close.
119
+
120
+ ## Tie a WebSocket to its work
121
+
122
+ <!-- docs-check: continue -->
123
+
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
+
126
+ First the command. It sends a price now and then every second, until its task is aborted.
127
+
128
+ ```js
129
+ // prices.js
130
+ import { createCommand } from "nanoeffects/actions";
131
+ import { poll } from "nanoeffects/effects";
132
+ import { latest } from "nanoeffects/policies";
133
+
134
+ // createCommand — a command with a handler
135
+ // latest — a new subscription stops the old one; key — but only the old one of the same socket
136
+ export const watchPrices = createCommand(
137
+ "prices/watch",
138
+ latest(
139
+ async (call, ctx) => {
140
+ // poll — call the function now and every second, until the task is aborted
141
+ await poll(ctx, () => call.send(`${call.symbol}: ${Math.random()}`), 1000, {
142
+ leading: true,
143
+ });
144
+ },
145
+ { key: (call) => call.socketId },
146
+ ),
147
+ );
148
+ ```
149
+
150
+ Without the `key`, one policy serves the whole command, and a new subscription on one socket would stop the subscription on another.
151
+
152
+ Then the route. Each socket gets an id and a controller.
153
+
154
+ ```js
155
+ // sockets.js
156
+ import websocket from "@fastify/websocket";
157
+ import Fastify from "fastify";
158
+ import { bus } from "./bus";
159
+ import { watchPrices } from "./prices";
160
+
161
+ const sockets = Fastify();
162
+ sockets.register(websocket);
163
+
164
+ let lastSocketId = 0;
165
+
166
+ sockets.register(async (instance) => {
167
+ instance.get("/prices", { websocket: true }, (socket) => {
168
+ lastSocketId += 1;
169
+ const socketId = lastSocketId;
170
+ const controller = new AbortController();
171
+ const { signal } = controller;
172
+
173
+ // close — the client left: stop everything started for this socket
174
+ socket.on("close", () => controller.abort());
175
+
176
+ socket.on("message", (data) => {
177
+ const send = (text) => socket.send(text);
178
+ const call = watchPrices({ socketId, symbol: String(data), send });
179
+ // emit with { signal } — the call stops when the socket closes
180
+ bus.emit(call, { signal });
181
+ });
182
+ });
183
+ });
184
+ ```
185
+
186
+ The [ws-server example](../examples/ws-server) adds a close for a silent socket, made of `race`, `take` and `delay`.
187
+
188
+ ## Run a queue worker
189
+
190
+ A worker takes jobs from a client and answers at once, and the work goes on in the background. Use `createJob` with `queue` to limit how many run together, `retry` and `timeout` to give up, and the outcomes of the job (`pending`, `success`, `fail`, `cancel`) to count them. Give every `emit` of the job the same signal; aborting it at shutdown cancels the jobs that are left.
191
+
192
+ Two details matter:
193
+
194
+ - Bind the handler with `commands.handle(job, queue(...))` (or `m.handle` in a module), not as the second argument of `createJob`. Only a bound handler lets the job learn from the policy when a call really starts, so `pending` is sent at the start and not at the call.
195
+ - A call that is still waiting in the queue when the signal aborts never starts, and the job sends no outcome for it. Count such calls yourself if you need the totals to add up.
196
+
197
+ The [job-worker example](../examples/job-worker) has all of it: the counts, `GET /stats`, and a shutdown in three steps (stop taking jobs, wait, abort the rest).
198
+
199
+ ## Examples
200
+
201
+ | Folder | What it shows |
202
+ | ------ | ------------- |
203
+ | [`api-server`](../examples/api-server) | A Fastify server over PokeAPI: `timeout`, `retry`, `allSettled`, and the abort when the client leaves |
204
+ | [`ws-server`](../examples/ws-server) | A WebSocket server: one controller for each socket, `latest` with a `key`, an idle close |
205
+ | [`job-worker`](../examples/job-worker) | A queue worker: `queue`, `retry`, `timeout`, counts from the outcomes, a three-step shutdown |
package/docs/starter.md CHANGED
@@ -48,7 +48,7 @@ Pass `--template` to skip the question. With npm, put `--` before the option.
48
48
  | `react-ts` | The `react` app in TypeScript | You build a React app in TypeScript |
49
49
  | `domains-ts` | Commands, a job and modules, with the logic split into domain folders and React as a thin layer | You build an app with more than one feature and want the logic off the UI |
50
50
  | `vue-ts` | The `react-ts` app in Vue 3, with `watchEmitter` and `useEventRef` from `nanoeffects/vue` | You build a Vue 3 app |
51
- | `svelte-ts` | The `react-ts` app in Svelte 5, with a Svelte store made from an event | You build a Svelte app |
51
+ | `svelte-ts` | The `react-ts` app in Svelte 5, with `eventStore` from `nanoeffects/svelte` | You build a Svelte app |
52
52
 
53
53
  ```bash
54
54
  npm create nanoeffects@latest my-app -- --template react
@@ -74,7 +74,7 @@ npm create nanoeffects@latest my-app -- --template vue-ts
74
74
 
75
75
  ### Start with Svelte
76
76
 
77
- `svelte-ts` is the search app of `react-ts` in Svelte 5. It needs no part of nanoeffects for Svelte: a Svelte store is any object with `subscribe`, so a small helper of the project, `src/event-store.ts`, makes a store from an event with `readable` and `bus.on`. In a component, `$found` reads it, and Svelte subscribes and unsubscribes for you. The query goes to the bus from the `oninput` handler, so nothing is emitted when the component mounts. The store listens only while something reads it, and is [not a store of the app](react.md#show-the-last-value-of-an-event-as-state): a value that several components share belongs in a store of your own.
77
+ `svelte-ts` is the search app of `react-ts` in Svelte 5. Its bus uses `sveltePlugin` from `nanoeffects/svelte`: `eventStore` makes a Svelte store from an event. In a component, `$found` reads it, and Svelte subscribes and unsubscribes for you. The query goes to the bus from the `oninput` handler, so nothing is emitted when the component mounts. The store listens only while something reads it, and is [not a store of the app](svelte.md#it-is-not-a-store-of-the-app): a value that several components share belongs in a store of your own.
78
78
 
79
79
  ```bash
80
80
  npm create nanoeffects@latest my-app -- --template svelte-ts
@@ -98,7 +98,7 @@ npm create nanoeffects@latest my-app -- --template domains-ts
98
98
  | `src/domains/checkout/` | Placing an order: `checkout.ts` (a job), `checkout-handlers.ts` (`leading` and `retry`), `checkout-store.ts` (state written from the job's outcomes), `checkout-module.ts`. |
99
99
  | `src/ui/` | The components. None of them calls the fake server in `src/api.ts`. |
100
100
 
101
- For a bigger example with the same layout, a chat with four domains and mocks, see [Build a chat with domains, modules and a small server](chat-example.md).
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
103
  The commands, jobs and modules themselves are explained in [Actions, commands, jobs and modules](actions.md). The template keeps found products in a Context provider with one `useEventState`, and the order state in a store of its own: `useEventState` is [not a store](react.md#show-the-last-value-of-an-event-as-state).
104
104
 
package/docs/stores.md CHANGED
@@ -25,7 +25,7 @@ Redux’s `dispatch` → reducers updates **state**. It does not give you first-
25
25
 
26
26
  `createEffectsMiddleware(bus)` does the same job: after the reducer runs, the action also hits the nanoeffects bus you pass to it. Keep RTK for state; use policies and effects for async.
27
27
 
28
- Skip this section if you are not already on Redux. The recipe as a file, type-checked with the project: [`examples/redux.ts`](../examples/redux.ts).
28
+ Skip this section if you are not already on Redux. The recipe as a file, type-checked with the project: [`examples/redux.ts`](../examples/redux.ts). To run it, open the [counter example](../examples/counter): `redux.html` is a counter with Redux Toolkit and this middleware.
29
29
 
30
30
  ```js
31
31
  // bus.js
package/docs/svelte.md ADDED
@@ -0,0 +1,81 @@
1
+ # Use nanoeffects in a Svelte app
2
+
3
+ Use this page when your app is in Svelte 5 and a component has to show the result of an event. `sveltePlugin` gives you one function for that, `eventStore`. To see it in a running project, start from the `svelte-ts` template in [Start a project from a template](starter.md#start-with-svelte).
4
+
5
+ You need Svelte 5 or newer and `nanoeffects` installed. If you do not know what the bus is, read the [Guide](guide.md) first.
6
+
7
+ ## Add the plugin to the bus
8
+
9
+ **Problem.** A Svelte component reads a value with `$store`. An event of the bus is not a store, so without help the component writes its own `readable` and its own `bus.on`, and has to remember to stop listening. The same few lines are copied into every component.
10
+
11
+ **When.** A component shows the payload of an event, such as the result of a search.
12
+
13
+ `sveltePlugin()` adds `eventStore` to the bus. Take it out of the bus once, in the file that makes it, and import it where you need it:
14
+
15
+ ```js
16
+ // bus.js
17
+ import { createBus } from "nanoeffects";
18
+ import { sveltePlugin } from "nanoeffects/svelte";
19
+
20
+ // createBus — the event bus, created once outside Svelte
21
+ // sveltePlugin — adds eventStore to the bus
22
+ export const bus = createBus().use(sveltePlugin());
23
+ export const { eventStore } = bus; // a free function: it does not read `this`
24
+ ```
25
+
26
+ ## Show the result of an event
27
+
28
+ <!-- docs-check: continue -->
29
+
30
+ `eventStore(event, initial)` returns a Svelte store. Its value is `initial` at first and the payload of every `event` after that. In a component, `$found` reads it, and Svelte subscribes and unsubscribes for you. A payload that is a function is stored, not called.
31
+
32
+ ```js
33
+ // search.js
34
+ import { createAction } from "nanoeffects/actions";
35
+
36
+ // createAction — an event with a name; calling it builds { type, payload }
37
+ export const searchLoaded = createAction("search/loaded");
38
+ ```
39
+
40
+ <!-- docs-check: skip - a .svelte file -->
41
+ ```svelte
42
+ <script>
43
+ import { eventStore } from "./bus";
44
+ import { searchLoaded } from "./search";
45
+
46
+ // eventStore — like a store of [], but searchLoaded sets the value
47
+ const found = eventStore(searchLoaded, []);
48
+ </script>
49
+
50
+ <ul>
51
+ {#each $found as title (title)}
52
+ <li>{title}</li>
53
+ {/each}
54
+ </ul>
55
+ ```
56
+
57
+ In TypeScript the type of the store comes from the event: with `createAction<string[]>("search/loaded")` it is `Readable<string[]>`, and for an event with no payload type it comes from `initial`.
58
+
59
+ To send a value to the bus, call `bus.emit` from the handler of the event, for example `oninput`. Nothing is emitted when the component mounts, so no plugin function is needed for that direction.
60
+
61
+ ### It is not a store of the app
62
+
63
+ The bus listens only while something reads the store: the first `$found` starts it, and the last one stops it. The bus keeps no values, so an event that came while nothing read the store is lost, and a component that mounts later starts from `initial`. To keep a value for components that come later, keep it in a store of your own and write to it from a handler of the event:
64
+
65
+ ```js
66
+ // found-store.js
67
+ import { writable } from "svelte/store";
68
+ import { bus } from "./bus";
69
+ import { searchLoaded } from "./search";
70
+
71
+ // writable — a Svelte store that you keep for the whole app
72
+ export const foundTitles = writable([]);
73
+
74
+ // bus.on — copy every searchLoaded into the store, whether or not a component is open
75
+ bus.on(searchLoaded, (titles) => foundTitles.set(titles));
76
+ ```
77
+
78
+ ## Where to go next
79
+
80
+ - The same job for React: [Build a sign-up form in React](react.md); for Vue: [Use nanoeffects in a Vue app](vue.md).
81
+ - A policy in a component (a debounced save): in the script of a component make it once with [`withPolicy`](policies.md#use-a-policy-without-a-bus); Svelte runs the script once, so it needs no hook.