nanoeffects 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions.d.ts +2 -2
- package/dist/actions.js +1 -1
- package/dist/{bus-duCCrqrr.js → bus-hHp-T7JV.js} +1 -1
- package/dist/context-UcmrAIhP.js +1 -0
- package/dist/{notrace/delay-BosJKFjg.js → delay-mxZkTg17.js} +1 -1
- package/dist/dev.d.ts +1 -1
- package/dist/effects.d.ts +4 -4
- package/dist/effects.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{module-1sUEXIcD.d.ts → module-D1VVKbE0.d.ts} +1 -1
- package/dist/notrace/actions.d.ts +1 -1
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/{bus-BkUtjPHp.js → bus-BdyvumpX.js} +1 -1
- package/dist/notrace/context-_HWcdjZf.js +1 -0
- package/dist/{delay-BnBdGPh7.js → notrace/delay-CqKplsaY.js} +1 -1
- package/dist/notrace/effects.d.ts +4 -4
- package/dist/notrace/effects.js +1 -1
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/{plugin-ByDuI8Vq.d.ts → plugin-BEvYzo8Q.d.ts} +32 -26
- package/dist/notrace/policies.js +1 -1
- package/dist/oop.js +1 -1
- package/dist/{plugin-BiGv84Xu.d.ts → plugin-BZK83Z3N.d.ts} +32 -26
- package/dist/policies.js +1 -1
- package/dist/react.js +1 -1
- package/dist/scope.d.ts +1 -1
- package/dist/scope.js +1 -1
- package/dist/signal.js +1 -1
- package/dist/trace.js +1 -1
- package/dist/{types-DC-aLJit.d.ts → types-CBoV9m7W.d.ts} +1 -1
- package/dist/{with-policy-CW50Edgk.js → with-policy-D-SQRkPh.js} +1 -1
- package/docs/README.md +1 -0
- package/docs/actions.md +1 -1
- package/docs/chat-example.md +33 -27
- package/docs/effects.md +28 -4
- package/docs/migrating-from-rxjs.md +2 -2
- package/docs/server.md +202 -0
- package/docs/starter.md +1 -1
- package/llms-full.txt +271 -35
- package/llms.txt +2 -1
- package/package.json +7 -4
- package/dist/context-CJGW6OWj.js +0 -1
- package/dist/notrace/context-yYU3_9lq.js +0 -1
package/dist/scope.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { P as WithSignal, a as EmitScope, d as Effects, f as Signaled, h as Bus, o as Scope, u as Effect, z as EventMap } from "./action-creator-Bpa8BUS9.js";
|
|
2
|
-
import { n as ScopeSlots, r as Scoped } from "./types-
|
|
2
|
+
import { n as ScopeSlots, r as Scoped } from "./types-CBoV9m7W.js";
|
|
3
3
|
//#region src/scope/effect.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* Marks a function whose first parameter is `{ signal }` as an effect, so that
|
package/dist/scope.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./scope-key-B7l7mUS5.js";const t=/* @__PURE__ */ new WeakSet;function n(e){return t.add(e),e}const r=/* @__PURE__ */ new Set([`signal`,`abort`,`emit`,`on`,`off`,`run`,`type`,`payload`,`traceGroupId`]),i=[],a=Symbol(`nanoeffects.scope.call`),o=Symbol(`nanoeffects.scope.module`),s=Symbol(`nanoeffects.scope.watched`),c=e=>e,l=e=>typeof e==`function`&&t.has(e),u=(e,t)=>(...n)=>Reflect.apply(t,null,[e,...n]),d=(e,t)=>{if(!t)return;for(let n in t)if(n!==`$effects`&&!r.has(n)){let r=t[n];e[n]=l(r)?u(e,r):r}let n=t.$effects;if(n)for(let t in n){let i=n[t];i&&!r.has(t)&&(e[t]=u(e,i))}},f=(e,t,n)=>{d(e,n);for(let n of t)d(e,n)},p=e=>typeof e==`object`&&!!e,m=e=>p(e),h=e=>(p(e)||typeof e==`function`)&&`type`in e,g=e=>h(e)?e.type:e,_=(e,t)=>p(e)&&h(e)?e.payload:t[0],v=t=>p(t)||typeof t==`function`?Reflect.get(t,e):void 0,y=(e,t)=>{let n=v(e),r=p(e)&&m(t[0])?t[0]:void 0;if(n&&r)return[n,r];let i=n??r;return i?[i]:void 0},b=()=>e=>{let t,n,r,l=e=>{let n=e.run;if(n[s])return;let r=(r,...i)=>{let s=t;t=void 0;try{return n((t,n)=>{let i=e[a];return i&&u(n,i,e[o]),r(t,n)},...i)}finally{t=s}};r[s]=!0,e.run=r},u=(e,t,n)=>{e[a]=t,n&&(e[o]=n),f(e,t,n),l(e)},d=e.addContext;e.addContext=e=>{d?.(e);let i=c(e);t&&i.type===n&&Object.is(i.payload,r)
|
|
1
|
+
import{t as e}from"./scope-key-B7l7mUS5.js";const t=/* @__PURE__ */ new WeakSet;function n(e){return t.add(e),e}const r=/* @__PURE__ */ new Set([`signal`,`abort`,`emit`,`on`,`off`,`run`,`type`,`payload`,`traceGroupId`]),i=[],a=Symbol(`nanoeffects.scope.call`),o=Symbol(`nanoeffects.scope.module`),s=Symbol(`nanoeffects.scope.watched`),c=e=>e,l=e=>typeof e==`function`&&t.has(e),u=(e,t)=>(...n)=>Reflect.apply(t,null,[e,...n]),d=(e,t)=>{if(!t)return;for(let n in t)if(n!==`$effects`&&!r.has(n)){let r=t[n];e[n]=l(r)?u(e,r):r}let n=t.$effects;if(n)for(let t in n){let i=n[t];i&&!r.has(t)&&(e[t]=u(e,i))}},f=(e,t,n)=>{d(e,n);for(let n of t)d(e,n)},p=e=>typeof e==`object`&&!!e,m=e=>p(e),h=e=>(p(e)||typeof e==`function`)&&`type`in e,g=e=>h(e)?e.type:e,_=(e,t)=>p(e)&&h(e)?e.payload:t[0],v=t=>p(t)||typeof t==`function`?Reflect.get(t,e):void 0,y=(e,t)=>{let n=v(e),r=p(e)&&m(t[0])?t[0]:void 0;if(n&&r)return[n,r];let i=n??r;return i?[i]:void 0},b=()=>e=>{let t,n,r,l=e=>{let n=e.run;if(n[s])return;let r=(r,...i)=>{let s=t;t=void 0;try{return n((t,n)=>{let i=e[a];return i&&u(n,i,e[o]),r(t,n)},...i)}finally{t=s}};r[s]=!0,e.run=r},u=(e,t,n)=>{e[a]=t,n&&(e[o]=n),f(e,t,n),l(e)},d=e.addContext;e.addContext=e=>{d?.(e);let i=c(e);t&&i.type===n&&Object.is(i.payload,r)?u(i,t,i[o]):i[a]&&u(i,i[a],i[o])},e.applyScope=(e,t)=>{let n=c(e);u(n,n[a]??i,t)},e.fitScope=(t,n)=>typeof n==`function`?(r,i)=>(e.applyScope?.(i,t),Reflect.apply(n,null,[r,i])):n;let p=e.emit;return e.emit=(e,...i)=>{let a=y(e,i);if(!(a||t))return p(e,...i);let o=t,s=n,c=r;t=a,n=g(e),r=_(e,i);try{return p(e,...i)}finally{t=o,n=s,r=c}},e};export{n as effect,b as scopePlugin};
|
package/dist/signal.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{u as e}from"./context-
|
|
1
|
+
import{u as e}from"./context-UcmrAIhP.js";import{n as t}from"./promise-CSLpzwSR.js";import{t as n}from"./bus-hHp-T7JV.js";const r=e=>typeof e==`object`&&!!e,i=(e,t)=>{let n=t[0];if(!r(n)||!r(e)&&typeof e!=`function`)return;let i=Reflect.get(n,`signal`);return i instanceof AbortSignal?i:void 0},a=(r,i,a,o)=>{let s=e({signal:r},e=>{for(let{target:t}of i)t.abort(e)});return t(o)?o.then(s,s):n(a)&&s(),o},o=()=>e=>{let t,n,r=e.addContext;e.addContext=e=>{r?.(e),t?.push({target:e}),n?.aborted&&e.abort(n.reason)};let o=e.emit;return e.emit=(e,...r)=>{let s=i(e,r);if(!(s||t))return o(e,...r);let c=t,l=n,u=[];t=s?u:void 0,n=s;let d;try{d=o(e,...r)}finally{t=c,n=l}return s?a(s,u,e,d):d},e};export{o as signalPlugin};
|
package/dist/trace.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{a as e,c as t,d as n,n as r,o as i,r as a,s as o,u as s}from"./context-
|
|
1
|
+
import{a as e,c as t,d as n,n as r,o as i,r as a,s as o,u as s}from"./context-UcmrAIhP.js";import{n as c}from"./promise-CSLpzwSR.js";import{t as l}from"./origin-key-BbWN3gmN.js";import{t as u}from"./scope-key-B7l7mUS5.js";const d=(e={})=>{let t=e.console??globalThis.console,n=e.prefix??`[nanoeffects]`,r=/* @__PURE__ */ new Map;return({id:e,parentId:i,type:a,effect:o,phase:s,payload:c,error:l,traceGroupId:u})=>{r.has(e)||r.set(e,typeof i==`number`?(r.get(i)??0)+1:0);let d=` `.repeat(r.get(e)??0);s!==`start`&&r.delete(e);let f=u===void 0?``:` [${u}]`,p=`${n}${f} ${d}${a} · ${o} ${s}`;s===`error`?t?.error(p,l):t?.debug(p,c)}},f=(e={})=>{let t=e.performance??globalThis.performance,n=/* @__PURE__ */ new Map;return({id:e,parentId:r,rootId:i,traceGroupId:a,type:o,effect:s,phase:c,error:l})=>{if(!t)return;if(c===`start`){n.set(e,t.now());return}let u=`${o} · ${s} (${c})`,d={id:e,parentId:r,...i!==void 0&&{rootId:i},...a!==void 0&&{traceGroupId:a},phase:c,...c===`error`&&{error:String(l)}},f=n.get(e);n.delete(e),typeof f==`number`?t.measure(u,{start:f,end:t.now(),detail:d}):t.mark(u,{detail:d})}};let p;const m=()=>p,h=(e,t)=>{let n=p;p=e;try{return t()}finally{p=n}},g=(e,t,n={})=>{let{trace:i}=e;if(!i){e.emit(t);return}let{traceId:a,traceGroupId:s}=n,{ctx:c,unlink:l}=r({event:{type:a??t.type,payload:t,...s===void 0?{}:{traceGroupId:s}},bus:{emit:e.emit,on:e.on,off:e.off,ctx:e.ctx,addContext:e.addContext},tracer:i});o(c,`run`,`start`);try{c.emit(t),o(c,`run`,`done`)}catch(e){throw o(c,`run`,`error`,e),e}finally{l()}},_=e=>typeof e==`string`?e:Reflect.get(Object(e),`type`),v=(e,t)=>typeof e==`object`?Reflect.get(Object(e),`payload`):t[0],y=()=>n=>{if(!(t&&n.trace))return n;let{trace:r}=n,o=(e,t)=>{let n=r.task({type:_(e),payload:v(e,t)},a());r.report(n,`emit`,`done`)},s=n.emit;n.emit=(e,...t)=>h(e,()=>(o(e,t),s(e,...t))),Reflect.set(n,l,(e,t)=>{g(n,e,t)});let c=/* @__PURE__ */ new WeakMap,u=(e,t,n)=>{let r=c.get(t)??/* @__PURE__ */ new Map,i=_(e);r.set(i,[...r.get(i)??[],n]),c.set(t,r)},d=(e,t)=>(c.get(t)?.get(_(e)))?.pop(),f=(t,n)=>(r,a)=>(e(a,n),i(a,`run`,()=>t(r,a))),p=(e,t)=>(n,r,i)=>{let a=Reflect.get(Object(i),`traceId`);if(typeof a!=`string`)return e(n,r);let o=t?(e,t)=>(d(n,r),f(r,a)(e,t)):f(r,a);u(n,r,o);let s=e(n,o);return()=>{s()}},m=n.off,y=(e,t)=>{t===void 0?m(e):m(e,d(e,t)??t)},b=n.on,x=n.once;return n.on=p(b,!1),n.once=p(x,!0),n.off=y,n},b=()=>{let e=[];return Object.assign(t=>{e.push(t)},{events:e,clear:()=>{e.length=0}})},x=()=>{},S=/* @__PURE__ */ new Set([`cancel`,`drop`,`error`]),C=()=>!1,w=()=>!0,T=e=>{if(typeof e==`function`)return e;if(Array.isArray(e)){let t=new Set(e);return({phase:e})=>t.has(e)}return e?w:C},E=(e={})=>{let t=e.extension??globalThis.__REDUX_DEVTOOLS_EXTENSION__;if(!t)return x;let{starts:n=!1,depth:r=0,maxAge:i}=e,a=T(e.policies),o=e.payload??`essential`,s=t.connect({name:e.name??`nanoeffects`,...i!==void 0&&{maxAge:i}}),c=/* @__PURE__ */ new Map,l=/* @__PURE__ */ new Map,u=()=>({running:Object.fromEntries(c)});s.init(u());let d=(e,t,n)=>n===0?a({effect:e,phase:t}):n<=r,f=(e,t)=>{e.phase===`start`?(l.set(e.id,t),d(e.effect,`start`,t)&&c.set(e.id,{type:e.type,effect:e.effect,...e.traceGroupId!==void 0&&{traceGroupId:e.traceGroupId}})):(l.delete(e.id),c.delete(e.id))},p=!1,m=!1,h=(e,t)=>{p||m||t!==0||e===`start`||(m=!0,globalThis.console?.warn(`[nanoeffects] reduxDevtools got a task but no emit event: add bus.use(tracePlugin()) to see the emits.`))},g=e=>{let{payload:t,...n}=e;return o===`all`||o===`essential`&&(e.effect===`emit`||S.has(e.phase))?{...n,payload:t}:n},_=(e,t)=>{s.send({...g(e),type:`${e.type}/${e.effect}/${e.phase}`,event:e.type,...e.phase===`error`&&{error:t instanceof Error?t.message:t}},u())};return({error:e,...t})=>{if(t.effect===`emit`){p=!0,s.send({...g(t),event:t.type},u());return}let r=(t.parentId===void 0?-1:l.get(t.parentId)??-1)+1;f(t,r),h(t.phase,r),(t.phase===`error`&&r===0||d(t.effect,t.phase,r))&&(t.phase!==`start`||n)&&_(t,e)}},D=e=>{let t=typeof e==`function`?[e]:[...e??[]];return t.length===0?null:e=>{for(let r of t)try{r(e)}catch(e){n(`trace logger failed`,e)}}},O=()=>{},k=e=>{if(typeof e.traceGroupId==`string`)return e.traceGroupId;let t=m();if(typeof t!=`object`&&typeof t!=`function`||t===null)return;let n=Reflect.get(t,u),r=typeof n==`object`&&n?Reflect.get(n,`traceGroupId`):void 0;return typeof r==`string`?r:void 0},A=(e,t={})=>{let n=D(e),{filter:r,sample:i=1}=t,a=0,o=e=>typeof i==`function`?i({type:e.type,payload:e.payload}):Math.random()<i,l={task:(e,t)=>({id:++a,root:t?.root??a,parent:t?.id,type:e.type,payload:e.payload,sampled:t?.sampled??o(e),group:k(e)??t?.group}),rename:(e,t)=>{e.type=t},report:(e,t,i,a)=>{if(!e.sampled)return;let o={id:e.id,type:e.type,effect:t,phase:i,payload:e.payload};typeof e.parent==`number`&&(o.parentId=e.parent),e.root!==e.id&&(o.rootId=e.root),e.group!==void 0&&(o.traceGroupId=e.group),i===`error`&&(o.error=a),(!r||r(o))&&n?.(o)},settler:(e,t)=>{if(!e.sampled)return O;let n=!1;return(r,i)=>{n||(n=!0,l.report(e,t,r,i))}},run:(e,t,n,r)=>{if(!e.sampled)return r();let i=l.settler(e,t);l.report(e,t,`start`),s({signal:n},()=>i(`cancel`));let a;try{a=r()}catch(e){throw i(`error`,e),e}return c(a)?Promise.resolve(a).then(e=>(i(`done`),e),e=>{throw i(`error`,e),e}):(i(`done`),a)}};return l};export{d as consoleLogger,b as createTraceRecorder,A as createTracer,f as performanceLogger,E as reduxDevtools,y as tracePlugin};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { d as Effects, o as Scope, s as WithFields, z as EventMap } from "./action-creator-Bpa8BUS9.js";
|
|
2
|
-
import { t as BoundOf } from "./plugin-
|
|
2
|
+
import { t as BoundOf } from "./plugin-BZK83Z3N.js";
|
|
3
3
|
//#region src/scope/types.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* The slots `scopePlugin` fills for `modulePlugin`. They are not on `Bus`: a bus
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{n as e}from"./context-
|
|
1
|
+
import{n as e}from"./context-UcmrAIhP.js";const t=Reflect.get(Symbol,`dispose`),n=n=>{let r=new AbortController,i=async(...t)=>{let i=t[0],{ctx:a,unlink:o}=e({event:{type:`withPolicy`,payload:i},parent:{signal:r.signal}});try{return await n(i,a)}finally{o()}},a=e=>{r.abort(e),r=new AbortController},o=Object.assign(i,{cancel:a});return t&&Reflect.set(o,t,()=>a()),o};export{n as t};
|
package/docs/README.md
CHANGED
|
@@ -17,6 +17,7 @@ Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-che
|
|
|
17
17
|
| Unreliable network / long flows | [Effects](effects.md) — one helper per job, then the rest as reference |
|
|
18
18
|
| Domain logic off the UI | [Actions, commands, modules](actions.md) |
|
|
19
19
|
| Already on Redux / Zustand / MobX / Pinia | [Stores](stores.md) |
|
|
20
|
+
| A Node server (Fastify) whose work must stop when the client leaves | [Stop work on a server](server.md) |
|
|
20
21
|
| A React form with async checks | [Build a sign-up form in React](react.md) |
|
|
21
22
|
| A Vue app | [Use nanoeffects in a Vue app](vue.md) |
|
|
22
23
|
| A Svelte app | [Use nanoeffects in a Svelte app](svelte.md) |
|
package/docs/actions.md
CHANGED
|
@@ -6,7 +6,7 @@ If you only need `on` / `emit` or policies, stay in the [Guide](guide.md) until
|
|
|
6
6
|
|
|
7
7
|
Recipe without a UI framework: [`examples/modules.ts`](../examples/modules.ts).
|
|
8
8
|
|
|
9
|
-
A chat with
|
|
9
|
+
A chat with two entities and three features, jobs under policies and effects, and a stream tied to a module: [Build a chat with entities, features, modules and a small server](chat-example.md).
|
|
10
10
|
|
|
11
11
|
A whole project with two domains, a job and React as a thin layer: `npm create nanoeffects@latest my-app -- --template domains-ts` (see [Start a project from a template](starter.md#start-with-domains)).
|
|
12
12
|
|
package/docs/chat-example.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Build a chat with
|
|
1
|
+
# Build a chat with entities, features, modules and a small server
|
|
2
2
|
|
|
3
3
|
Use this page when your app has several features that react to each other and you want to see where each piece of logic goes. The example is a chat with rooms, a history that loads, messages that can fail to send, a search across all rooms, and new messages that arrive on their own. A small Fastify server in the example answers every request, and it misbehaves on a known schedule, so each problem on this page really happens.
|
|
4
4
|
|
|
@@ -30,21 +30,27 @@ Open the Network panel of the browser while you do this. A request that the page
|
|
|
30
30
|
|
|
31
31
|
## See how the files are split
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
The code is in layers, and a file imports only from the layers below it. The components only emit events and read values; they never call the server.
|
|
34
34
|
|
|
35
|
-
| Path | Job |
|
|
36
|
-
| ---- | --- |
|
|
37
|
-
| `src/bus.ts` | Creates the bus. It imports no events and no
|
|
38
|
-
| `src/
|
|
39
|
-
| `src/
|
|
40
|
-
| `
|
|
41
|
-
| `src/
|
|
42
|
-
| `src/
|
|
43
|
-
| `src/
|
|
44
|
-
| `src/
|
|
45
|
-
| `
|
|
35
|
+
| Path | Layer | Job |
|
|
36
|
+
| ---- | ----- | --- |
|
|
37
|
+
| `src/shared/bus.ts` | shared | Creates the bus. It imports no events, no entity and no feature. |
|
|
38
|
+
| `src/shared/api.ts` | shared | The client of the server: one function per request. |
|
|
39
|
+
| `src/entities/room/` | entity | The list of rooms and the selected room. |
|
|
40
|
+
| `src/entities/message/` | entity | The history of the open room, sending a message, and a message that arrives. |
|
|
41
|
+
| `src/features/show-history/` | feature | A selected room loads its history. |
|
|
42
|
+
| `src/features/search-messages/` | feature | Searching every room and opening a result. |
|
|
43
|
+
| `src/features/receive-messages/` | feature | New messages, asked for at an interval. |
|
|
44
|
+
| `src/ui/`, `src/app.ts` | top | The components, and the file that mounts the modules. |
|
|
45
|
+
| `server/` | | The Fastify server and the script that starts it with Vite. |
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
**The rule.** An entity is a thing with its own state and orders (a room, a message). It imports `shared` and nothing else. A feature is a scenario that connects things: it imports `shared` and entities, and never another feature. The top imports anything below. No two entities import each other and no two features import each other, and a test in the repository (`src/tests/architecture.test.ts`) fails when one does.
|
|
48
|
+
|
|
49
|
+
That rule is why `show-history` exists. Selecting a room must load its history, but `room` does not know `message` and `message` does not know `room`. The feature `show-history` is the one place that knows both: it listens to `rooms/selected` and starts the job `messages/loadHistory`. The same goes for a message that arrives: `receive-messages` emits `incoming/received`, an event that the `message` entity owns and listens to.
|
|
50
|
+
|
|
51
|
+
A folder has its events (`room.ts`, which imports no bus), its work (`room-handlers.ts`), the state it keeps (`room-store.ts`) and a module (`room-module.ts`) that wires them. Another layer reaches an entity through its events and commands, never through its handlers or its store. Events and commands both live in the events file. An event (`createAction`) says that something happened, and any feature may listen: `rooms/selected` is heard by two. A command (`createCommand`) is an order with exactly one handler, as `search/openResult` and `messages/mark` are. The handler is given to `createCommand`, so the command works wherever it is called and can return a result, and the module binds it with `module.use` for as long as it is mounted. A job is a command that also sends its outcomes, but its handler is bound in the module (`module.handle`): a job usually runs under a policy, and the type of its outcomes is exact only when the handler is not part of the type.
|
|
52
|
+
|
|
53
|
+
The layers come from [Feature-Sliced Design](https://feature-sliced.design/), reduced to the three that an app of this size needs.
|
|
48
54
|
|
|
49
55
|
## Load a history that must not race
|
|
50
56
|
|
|
@@ -55,7 +61,7 @@ A domain folder has its events (`rooms.ts`, which imports no bus), its work (`ro
|
|
|
55
61
|
A job sends `pending` when its handler starts and exactly one of `success`, `fail` and `cancel` when it ends. `latest` around the handler aborts the older load when a newer one starts, so only the newer result is delivered, and the older one sends `cancel`:
|
|
56
62
|
|
|
57
63
|
```js
|
|
58
|
-
//
|
|
64
|
+
// message.js
|
|
59
65
|
import { createJob } from "nanoeffects/actions";
|
|
60
66
|
|
|
61
67
|
// createJob — a command that also sends pending, success, fail and cancel
|
|
@@ -63,7 +69,7 @@ export const loadHistory = createJob("messages/loadHistory");
|
|
|
63
69
|
```
|
|
64
70
|
|
|
65
71
|
```js
|
|
66
|
-
//
|
|
72
|
+
// message-handlers.js
|
|
67
73
|
import { latest } from "nanoeffects/policies";
|
|
68
74
|
|
|
69
75
|
// latest — a load for a newer room aborts the older one, and its result is never delivered
|
|
@@ -73,12 +79,12 @@ export const fetchHistory = latest((roomId, { signal }) => api.loadHistory(roomI
|
|
|
73
79
|
The module gives the job its work and keeps the state from the outcomes, so an aborted load clears the flag too (see [Show a loading state with a job](actions.md#show-a-loading-state-with-a-job)):
|
|
74
80
|
|
|
75
81
|
```js
|
|
76
|
-
//
|
|
82
|
+
// message-module.js
|
|
77
83
|
import { createModule } from "nanoeffects/actions";
|
|
78
|
-
import { loadHistory } from "./
|
|
79
|
-
import { fetchHistory } from "./
|
|
84
|
+
import { loadHistory } from "./message";
|
|
85
|
+
import { fetchHistory } from "./message-handlers";
|
|
80
86
|
|
|
81
|
-
export const
|
|
87
|
+
export const messageModule = createModule("message", (module) => {
|
|
82
88
|
// handle — this module does the work of loadHistory while it is mounted
|
|
83
89
|
module.handle(loadHistory, fetchHistory);
|
|
84
90
|
module.on(loadHistory.pending, () => console.log("show the spinner"));
|
|
@@ -168,7 +174,7 @@ export const runSearch = debounce(
|
|
|
168
174
|
|
|
169
175
|
**Problem.** The reader opens a search result from another room. The message cannot be marked until that room's history has loaded, and the handler must not guess how long that takes.
|
|
170
176
|
|
|
171
|
-
**When.** A
|
|
177
|
+
**When.** A feature starts work in an entity and needs to continue after its outcome.
|
|
172
178
|
|
|
173
179
|
`take` waits for the next event of a name. Start it before the emit that causes the event, so nothing is missed:
|
|
174
180
|
|
|
@@ -176,7 +182,7 @@ export const runSearch = debounce(
|
|
|
176
182
|
import { createCommand } from "nanoeffects/actions";
|
|
177
183
|
import { take } from "nanoeffects/effects";
|
|
178
184
|
|
|
179
|
-
// createCommand — an order for the
|
|
185
|
+
// createCommand — an order for the message entity, with the handler that does it
|
|
180
186
|
const markMessage = createCommand("messages/mark", (messageId) => console.log("mark", messageId));
|
|
181
187
|
|
|
182
188
|
// the room that is open, kept from the rooms/selected event
|
|
@@ -189,7 +195,7 @@ export const goToResult = async (hit, ctx) => {
|
|
|
189
195
|
ctx.emit("rooms/selected", hit.roomId);
|
|
190
196
|
await loaded;
|
|
191
197
|
}
|
|
192
|
-
// ctx.emit with a command — the
|
|
198
|
+
// ctx.emit with a command — the message entity marks the message
|
|
193
199
|
ctx.emit(markMessage(hit.id));
|
|
194
200
|
};
|
|
195
201
|
```
|
|
@@ -227,20 +233,20 @@ const watchUpdates = async (_, ctx) => {
|
|
|
227
233
|
await poll(ctx, check, 3000, { leading: true });
|
|
228
234
|
};
|
|
229
235
|
|
|
230
|
-
export const
|
|
236
|
+
export const receiveMessagesModule = createModule("receive-messages", (module) => {
|
|
231
237
|
// spawn — a background task that lives as long as this module
|
|
232
238
|
module.spawn(watchUpdates);
|
|
233
239
|
});
|
|
234
240
|
```
|
|
235
241
|
|
|
236
|
-
In the example a button calls `app.unmount(
|
|
242
|
+
In the example a button calls `app.unmount(receiveMessagesModule)` and `app.mount(receiveMessagesModule)`, so you can watch the polling stop (the Network panel shows no more `updates` requests) and start.
|
|
237
243
|
|
|
238
244
|
## Know where each value lives
|
|
239
245
|
|
|
240
|
-
Each
|
|
246
|
+
Each entity and each feature that has state keeps it in a [Zustand](https://zustand.docs.pmnd.rs/) store that the module writes and a component reads with `useStore`. Each store lists its mutations, such as `startLoading(roomId)` and `markFailed(localId)`, and a module calls them with the data of an event: `module.on(loadHistory.pending, startLoading)`. A mutation that sets plain values gives `set` an object, as in `set({ searching: true })`. The store of the message entity uses the `immer` middleware, so a mutation that changes a list can change it in place, as in `state.messages.push(message)`, and the store still gives React a new object. Nothing else writes the state. The list of rooms is the exception: it is read with one `useEventState` in a provider at the root. `useEventState` is not a store: an event that came before the provider mounted is lost, and a second component gets its own copy. See [Show the last value of an event as state](react.md#show-the-last-value-of-an-event-as-state).
|
|
241
247
|
|
|
242
248
|
## Where to go next
|
|
243
249
|
|
|
244
250
|
- The API of commands, jobs and modules: [Actions, commands, jobs and modules](actions.md).
|
|
245
251
|
- Policies such as `latest` and `queue`: [Policies](policies.md). Effects such as `allSettled`, `take` and `poll`: [Effects](effects.md).
|
|
246
|
-
- A smaller project with two domains: [Start with domains](starter.md#start-with-domains).
|
|
252
|
+
- A smaller project with two domains: [Start with domains](starter.md#start-with-domains). Its two domains do not talk to each other, so it needs no layers.
|
package/docs/effects.md
CHANGED
|
@@ -161,7 +161,8 @@ bus.on("load", async (id, { race, all, delay }) => {
|
|
|
161
161
|
// race — same as race(ctx, …)
|
|
162
162
|
const result = await race({
|
|
163
163
|
data: ({ signal }) => fetchData(id, { signal }),
|
|
164
|
-
|
|
164
|
+
// a starter gets its own child with the same bound effects, so delay(5000) stops with the race
|
|
165
|
+
timeout: ({ delay }) => delay(5000),
|
|
165
166
|
});
|
|
166
167
|
if ("timeout" in result) throw new Error("timed out");
|
|
167
168
|
|
|
@@ -263,8 +264,8 @@ bus.on("login", async (user, ctx) => {
|
|
|
263
264
|
console.log(stopped);
|
|
264
265
|
|
|
265
266
|
// wait for a matching event, but not longer than 5 s
|
|
266
|
-
const id = await timeout(ctx, 5000, (
|
|
267
|
-
take(
|
|
267
|
+
const id = await timeout(ctx, 5000, (child) =>
|
|
268
|
+
take(child, "saved", { predicate: (savedId) => savedId === draft.id }),
|
|
268
269
|
);
|
|
269
270
|
});
|
|
270
271
|
```
|
|
@@ -272,7 +273,7 @@ bus.on("login", async (user, ctx) => {
|
|
|
272
273
|
- Sees only events emitted after the call, takes one, and unsubscribes.
|
|
273
274
|
- With an action creator the result has the payload type of the creator. A creator made without a payload type (`createAction("saved")`) takes it from the event map of the bus.
|
|
274
275
|
- `options.predicate` skips payloads it rejects. In TypeScript, a type guard (`(payload): payload is Admin`) narrows the type of the result.
|
|
275
|
-
- `options.signal` stops the wait
|
|
276
|
+
- `options.signal` stops the wait. In a starter of `race`, `all` or `timeout` pass `child` as the first argument instead: it already carries the signal.
|
|
276
277
|
- On abort of the task or of `options.signal`, unsubscribes and rejects with the abort reason.
|
|
277
278
|
|
|
278
279
|
## Run several starters together
|
|
@@ -301,6 +302,29 @@ bus.on("open", async (id, ctx) => {
|
|
|
301
302
|
|
|
302
303
|
`race(ctx, effects)` settles with the first starter that finishes and cancels the others.
|
|
303
304
|
|
|
305
|
+
```js
|
|
306
|
+
import { createBus } from "nanoeffects";
|
|
307
|
+
import { delay, race, take } from "nanoeffects/effects";
|
|
308
|
+
|
|
309
|
+
const bus = createBus();
|
|
310
|
+
|
|
311
|
+
bus.on("save", async (draft, ctx) => {
|
|
312
|
+
// race — the first starter to finish wins, the other is cancelled
|
|
313
|
+
const result = await race(ctx, {
|
|
314
|
+
waited: (child) => delay(child, 1000),
|
|
315
|
+
cancelled: (child) => take(child, "cancel"),
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
if ("waited" in result) {
|
|
319
|
+
ctx.emit("saved", draft);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
**A starter gets the context of its own child task.** Pass `child` as the first argument to any effect, as a handler passes `ctx`: when the race ends, `child` is aborted and stops exactly this effect. The outer `ctx` is only for `race` itself. Your own API takes the signal in its options: `({ signal }) => fetchUser(id, { signal })`. The same holds for `all`, `allSettled` and `timeout`.
|
|
325
|
+
|
|
326
|
+
If `ctx` is `{}` or a bare `{ signal }` (outside a task), `child` has only `signal` and `abort`, so it fits `delay` and `poll` but not `take`, which needs a bus.
|
|
327
|
+
|
|
304
328
|
## Pass `ctx` outside a task
|
|
305
329
|
|
|
306
330
|
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`.
|
|
@@ -122,9 +122,9 @@ bus.on("watch", async (_, ctx) => {
|
|
|
122
122
|
|
|
123
123
|
await race(ctx, {
|
|
124
124
|
// poll: a check every 5 s; waitForFn: the next pause starts after the check ends
|
|
125
|
-
polling: (
|
|
125
|
+
polling: (child) => poll(child, checkStatus, 5000, { waitForFn: true }),
|
|
126
126
|
// take: resolves at the next "leave", and race stops the polling
|
|
127
|
-
leave: (
|
|
127
|
+
leave: (child) => take(child, "leave"),
|
|
128
128
|
});
|
|
129
129
|
});
|
|
130
130
|
|
package/docs/server.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
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
|
+
One detail matters: 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.
|
|
193
|
+
|
|
194
|
+
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).
|
|
195
|
+
|
|
196
|
+
## Examples
|
|
197
|
+
|
|
198
|
+
| Folder | What it shows |
|
|
199
|
+
| ------ | ------------- |
|
|
200
|
+
| [`api-server`](../examples/api-server) | A Fastify server over PokeAPI: `timeout`, `retry`, `allSettled`, and the abort when the client leaves |
|
|
201
|
+
| [`ws-server`](../examples/ws-server) | A WebSocket server: one controller for each socket, `latest` with a `key`, an idle close |
|
|
202
|
+
| [`job-worker`](../examples/job-worker) | A queue worker: `queue`, `retry`, `timeout`, counts from the outcomes, a three-step shutdown |
|
package/docs/starter.md
CHANGED
|
@@ -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
|
|
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
|
|