nanoeffects 0.27.0 → 0.29.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 (84) hide show
  1. package/README.md +26 -24
  2. package/dist/{action-creator-D_c223C7.d.ts → action-creator-jeEqIKgK.d.ts} +73 -32
  3. package/dist/actions.d.ts +18 -18
  4. package/dist/actions.js +1 -1
  5. package/dist/bus-0uYn_kRj.js +1 -0
  6. package/dist/context-DtUnzC-1.js +1 -0
  7. package/dist/context-types-Bme1mzkn.d.ts +31 -0
  8. package/dist/context.d.ts +8 -3
  9. package/dist/context.js +1 -1
  10. package/dist/{delay-18LJ8jSk.js → delay-5OhJZZQ0.js} +1 -1
  11. package/dist/dev.d.ts +2 -2
  12. package/dist/dev.js +1 -1
  13. package/dist/effects.d.ts +2 -2
  14. package/dist/effects.js +1 -1
  15. package/dist/event-DVcW0mME.js +1 -0
  16. package/dist/index.d.ts +2 -2
  17. package/dist/index.js +1 -1
  18. package/dist/match.d.ts +3 -3
  19. package/dist/match.js +1 -1
  20. package/dist/{module-WX0Gngnt.d.ts → module-BWMW83qk.d.ts} +38 -38
  21. package/dist/notrace/{action-creator-Bux7Zzsl.d.ts → action-creator-xpQ_I2h8.d.ts} +73 -32
  22. package/dist/notrace/actions.d.ts +52 -52
  23. package/dist/notrace/actions.js +1 -1
  24. package/dist/notrace/bus-37nlVljc.js +1 -0
  25. package/dist/notrace/effects.d.ts +2 -2
  26. package/dist/notrace/index.d.ts +2 -2
  27. package/dist/notrace/index.js +1 -1
  28. package/dist/notrace/{plugin-CATXOxtj.d.ts → plugin-6LhNx0th.d.ts} +1 -1
  29. package/dist/notrace/policies.d.ts +1 -1
  30. package/dist/notrace/policies.js +1 -1
  31. package/dist/notrace/redux.d.ts +1 -1
  32. package/dist/observable.d.ts +43 -1
  33. package/dist/observable.js +1 -1
  34. package/dist/onAbort-BgMfspK4.js +1 -0
  35. package/dist/oop.d.ts +3 -3
  36. package/dist/oop.js +1 -1
  37. package/dist/{plugin-vvkmIIz2.d.ts → plugin-BaErevLy.d.ts} +2 -2
  38. package/dist/policies.d.ts +2 -2
  39. package/dist/policies.js +1 -1
  40. package/dist/react.d.ts +2 -2
  41. package/dist/react.js +1 -1
  42. package/dist/redux.d.ts +1 -1
  43. package/dist/scope.d.ts +9 -9
  44. package/dist/signal-field-key-CWT-3OXC.js +1 -0
  45. package/dist/signal.d.ts +34 -6
  46. package/dist/signal.js +1 -1
  47. package/dist/svelte.d.ts +1 -1
  48. package/dist/trace.d.ts +20 -5
  49. package/dist/trace.js +1 -1
  50. package/dist/{types-Bf-LX_tE.d.ts → types-D5xsR37j.d.ts} +2 -2
  51. package/dist/vue.d.ts +1 -1
  52. package/dist/{with-policy-CPjx0sLm.d.ts → with-policy-BZVRBUOm.d.ts} +1 -1
  53. package/dist/{with-policy-D-SQRkPh.js → with-policy-C6BIiPIi.js} +1 -1
  54. package/docs/README.md +14 -1
  55. package/docs/actions.md +28 -2
  56. package/docs/chat-example.md +16 -4
  57. package/docs/chat-widget.md +135 -0
  58. package/docs/context.md +33 -1
  59. package/docs/effects.md +20 -9
  60. package/docs/guide.md +21 -3
  61. package/docs/migrating-from-effector.md +11 -0
  62. package/docs/observable.md +43 -0
  63. package/docs/oop.md +18 -1
  64. package/docs/package.md +22 -13
  65. package/docs/plugins.md +69 -11
  66. package/docs/query-libraries.md +273 -5
  67. package/docs/react.md +12 -2
  68. package/docs/scope.md +14 -2
  69. package/docs/semantics.md +10 -0
  70. package/docs/server.md +3 -3
  71. package/docs/stability.md +2 -2
  72. package/docs/starter.md +3 -3
  73. package/docs/tracing.md +77 -9
  74. package/docs/typescript.md +1 -1
  75. package/llms-full.txt +816 -72
  76. package/llms.txt +8 -7
  77. package/package.json +6 -6
  78. package/skills/nanoeffects/SKILL.md +2 -2
  79. package/dist/bus-DQDHUN5e.js +0 -1
  80. package/dist/context-UcmrAIhP.js +0 -1
  81. package/dist/context-types-nGtzhjxD.d.ts +0 -14
  82. package/dist/event-0RIBGYqS.js +0 -1
  83. package/dist/notrace/bus-CGi2lrd5.js +0 -1
  84. package/dist/notrace/promise-CSLpzwSR.js +0 -1
package/dist/scope.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as EventMap, I as WithSignal, c as Scope, f as Effect, m as Signaled, p as Effects, s as EmitScope, v as Bus } from "./action-creator-D_c223C7.js";
2
- import { n as ScopeSlots, r as Scoped } from "./types-Bf-LX_tE.js";
1
+ import { H as EventMap, I as WithSignal, c as Scope, f as Effect, m as Signaled, p as Effects, s as EmitScope, v as Bus } from "./action-creator-jeEqIKgK.js";
2
+ import { n as ScopeSlots, r as Scoped } from "./types-D5xsR37j.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
@@ -16,7 +16,7 @@ import { n as ScopeSlots, r as Scoped } from "./types-Bf-LX_tE.js";
16
16
  * ```ts
17
17
  * const first = effect(<T>(ctx: WithSignal, items: T[]) => items[0]);
18
18
  *
19
- * const getUser = createCommand(
19
+ * const getUser = createAction(
20
20
  * "user/get",
21
21
  * (id: string, { first }) => first([id]), // first: <T>(items: T[]) => T
22
22
  * { first },
@@ -27,11 +27,11 @@ export declare function effect<A extends unknown[], R>(fn: (ctx: WithSignal, ...
27
27
  //#endregion
28
28
  //#region src/scope/plugin.d.ts
29
29
  /**
30
- * Switches scopes on for a bus: the scope given to a command, a job, an
31
- * action or a module (an object whose keys go on `ctx`, with `$effects` bound to
30
+ * Switches scopes on for a bus: the scope given to an action, a job
31
+ * or a module (an object whose keys go on `ctx`, with `$effects` bound to
32
32
  * the task), and the second argument of `emit`, put fields on
33
33
  * the context of the tasks they are for. A stronger layer replaces a field of
34
- * the same name: `emit`, then action / command / job, then module, then the bus.
34
+ * the same name: `emit`, then action / job, then module, then the bus.
35
35
  * Fields of the library (`signal`, `emit`, `run`, …) are never replaced. Returns
36
36
  * the same bus.
37
37
  *
@@ -41,13 +41,13 @@ export declare function effect<A extends unknown[], R>(fn: (ctx: WithSignal, ...
41
41
  * @example
42
42
  * ```ts
43
43
  * import { createBus } from "nanoeffects";
44
- * import { createCommand } from "nanoeffects/actions";
44
+ * import { createAction } from "nanoeffects/actions";
45
45
  * import { scopePlugin } from "nanoeffects/scope";
46
46
  *
47
47
  * const bus = createBus().use(scopePlugin()); // scopes work on this bus
48
48
  *
49
- * const getUser = createCommand("user/get", (id: string, { http }) => http.get(id), {
50
- * http, // a field of this command only
49
+ * const getUser = createAction("user/get", (id: string, { http }) => http.get(id), {
50
+ * http, // a field of this action only
51
51
  * });
52
52
  * bus.emit(getUser("1"), { http: fakeHttp }); // this call only
53
53
  * ```
@@ -0,0 +1 @@
1
+ const e=Symbol.for(`nanoeffects.signalField`);export{e as t};
package/dist/signal.d.ts CHANGED
@@ -1,4 +1,26 @@
1
- import { H as EventMap, O as EventLike, T as Emit, s as EmitScope, u as WithScope, v as Bus, x as CommandCall } from "./action-creator-D_c223C7.js";
1
+ import { A as HandledCall, C as Emit, E as EventLike, H as EventMap, s as EmitScope, u as WithScope, v as Bus } from "./action-creator-jeEqIKgK.js";
2
+ import { n as FromSignal } from "./context-types-Bme1mzkn.js";
3
+ //#region src/signal/from-signal.d.ts
4
+ /**
5
+ * Marks a field of `contextPlugin` as a value that needs the signal of its task: a
6
+ * client, a database handle. `make` runs when the task first reads the field, with the
7
+ * `ctx.signal` of that task, and runs once for it. A child task (`ctx.run`) makes its
8
+ * own with its own signal. Without `contextPlugin` the marker means nothing.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * const bus = createBus().use(
13
+ * contextPlugin({
14
+ * logger, // a plain field, the same for every task
15
+ * http: fromSignal((signal) => axios.create({ signal })), // one client per task, aborted with it
16
+ * }),
17
+ * );
18
+ *
19
+ * bus.on("user/open", (id, { http }) => http.get(`/users/${id}`));
20
+ * ```
21
+ */
22
+ export declare const fromSignal: <T>(make: (signal: AbortSignal) => T) => FromSignal<T>;
23
+ //#endregion
2
24
  //#region src/signal/plugin.d.ts
3
25
  /** What the second argument of `emit` takes besides the fields of a scope. */
4
26
  type SignalOption = {
@@ -7,8 +29,8 @@ type SignalOption = {
7
29
  };
8
30
  /** `emit` of a bus with `signalPlugin`: the call forms take `{ signal }` as their second argument. */
9
31
  type SignalEmit<E extends EventMap> = Emit<E> & {
10
- <P, R, F>(call: CommandCall<P, R> & WithScope<F>, options: SignalOption & EmitScope<E, F>): R;
11
- <P, R>(call: CommandCall<P, R>, options: SignalOption & EmitScope<E>): R;
32
+ <P, R, F>(call: HandledCall<P, R> & WithScope<F>, options: SignalOption & EmitScope<E, F>): R;
33
+ <P, R>(call: HandledCall<P, R>, options: SignalOption & EmitScope<E>): R;
12
34
  (event: EventLike<E>, options: SignalOption & EmitScope<E>): void;
13
35
  };
14
36
  /** A bus with `signalPlugin`: the same bus, with the `emit` that takes a signal. */
@@ -16,16 +38,22 @@ type SignalBus<E extends EventMap> = Bus<E> & {
16
38
  emit: SignalEmit<E>;
17
39
  };
18
40
  /**
41
+ * Also lets `bus.on(event, handler, { signal })` and `bus.once` remove the
42
+ * listener when the signal aborts, as `addEventListener` does, so a component or
43
+ * a script that holds a signal keeps no `unbind`. A handler that already runs is
44
+ * not aborted; a signal that is aborted already adds no listener. `off(event, fn)`
45
+ * does not end the link: the returned function does.
46
+ *
19
47
  * Lets `bus.emit(call, { signal })` tie the tasks that this emit starts to an
20
48
  * outside `AbortSignal`: when it aborts, their `ctx.signal` aborts with the same
21
49
  * reason, so a handler, `latest`, `retry` and `timeout` stop as for any abort. It
22
50
  * is made for libraries that own the request and give you a signal, such as the
23
51
  * `queryFn` of React Query. The tasks that count are those made while the call
24
- * to `emit` runs (the command or job and the listeners of its type, and their
52
+ * to `emit` runs (the action or job and the listeners of its type, and their
25
53
  * `ctx.run` children); a separate `emit` that a handler makes later is not
26
54
  * linked. `emit` returns what it returns without the plugin.
27
55
  *
28
- * For a command or a job the link ends when its promise settles; a handler
56
+ * For an action or a job the link ends when its promise settles; a handler
29
57
  * that returns without a promise is finished with the call, so it is not
30
58
  * linked after it. For an event the link stays until the signal aborts, so
31
59
  * give each request its own signal.
@@ -48,4 +76,4 @@ type SignalBus<E extends EventMap> = Bus<E> & {
48
76
  */
49
77
  export declare const signalPlugin: () => <E extends EventMap>(bus: Bus<E>) => SignalBus<E>;
50
78
  //#endregion
51
- export type { SignalBus, SignalEmit, SignalOption };
79
+ export type { FromSignal, SignalBus, SignalEmit, SignalOption };
package/dist/signal.js CHANGED
@@ -1 +1 @@
1
- import{u as e}from"./context-UcmrAIhP.js";import{n as t}from"./promise-CSLpzwSR.js";import{t as n}from"./bus-DQDHUN5e.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};
1
+ import{t as e}from"./onAbort-BgMfspK4.js";import{n as t}from"./promise-CSLpzwSR.js";import{n}from"./bus-0uYn_kRj.js";import{t as r}from"./signal-field-key-CWT-3OXC.js";const i=e=>({[r]:e}),a=(t,n)=>(r,i,a)=>{let o=a?.signal;if(!o)return t(r,i,a);let s=t(r,n?(e,t)=>(c(),i(e,t)):i,a),c=e({signal:o},s);return()=>{c(),s()}},o=e=>typeof e==`object`&&!!e,s=(e,t)=>{let n=t[0];if(!o(n)||!o(e)&&typeof e!=`function`)return;let r=Reflect.get(n,`signal`);return r instanceof AbortSignal?r:void 0},c=(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},l=()=>e=>{let t,n,r=e.addContext;e.addContext=e=>{r?.(e),t?.push({target:e}),n?.aborted&&e.abort(n.reason)};let i=e.emit;e.emit=(e,...r)=>{let a=s(e,r);if(!(a||t))return i(e,...r);let o=t,l=n,u=[];t=a?u:void 0,n=a;let d;try{d=i(e,...r)}finally{t=o,n=l}return a?c(a,u,e,d):d};let o=e.on,l=e.once;return e.on=a(o,!1),e.once=a(l,!0),e};export{i as fromSignal,l as signalPlugin};
package/dist/svelte.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { D as EventKey, H as EventMap, a as FreeActionCreator, t as ActionCreator, v as Bus } from "./action-creator-D_c223C7.js";
1
+ import { H as EventMap, T as EventKey, a as FreeActionCreator, t as ActionCreator, v as Bus } from "./action-creator-jeEqIKgK.js";
2
2
  import { t as StateOf } from "./state-of-C19H6BoV.js";
3
3
  import { Readable } from "svelte/store";
4
4
  //#region src/svelte/plugin.d.ts
package/dist/trace.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { B as Tracer, H as EventMap, J as Trace, K as Phase, V as EffectName, X as TraceLogger, Y as TraceEvent, v as Bus } from "./action-creator-D_c223C7.js";
1
+ import { B as Tracer, H as EventMap, J as Trace, K as Phase, V as EffectName, X as TraceLogger, Y as TraceEvent, v as Bus } from "./action-creator-jeEqIKgK.js";
2
2
  //#region src/trace/console.d.ts
3
3
  type ConsoleLoggerOptions = {
4
4
  /** Defaults to `globalThis.console`. */
@@ -52,7 +52,7 @@ export declare const performanceLogger: (options?: PerformanceLoggerOptions) =>
52
52
  /**
53
53
  * Names and groups what the trace shows, on a bus that has a tracer:
54
54
  *
55
- * - `traceGroupId` in the scope of a command, a job or an action goes to every
55
+ * - `traceGroupId` in the scope of an action, a job or an action goes to every
56
56
  * task that the call starts;
57
57
  * - `useEmitter` and `bus.emitter` emit from a root task named by their
58
58
  * `traceId`, so a chain says which subscription began it;
@@ -80,12 +80,21 @@ export declare const tracePlugin: () => <E extends EventMap>(bus: Bus<E>) => Bus
80
80
  //#endregion
81
81
  //#region src/trace/recorder.d.ts
82
82
  type Recorder<E = Record<string, unknown>> = TraceLogger<E> & {
83
- /** Every event received so far, in order. */
83
+ /**
84
+ * The events received so far, oldest first. Without a `capacity` it is one
85
+ * live array; with one, each read is a new array that is a snapshot.
86
+ */
84
87
  readonly events: TraceEvent<E>[];
85
88
  clear: () => void;
86
89
  };
87
90
  /**
88
- * Trace logger that keeps events in memory; meant for tests.
91
+ * Trace logger that keeps events in memory. With no option it keeps all of
92
+ * them, for tests. With `capacity` it keeps the last events, to send when
93
+ * something goes wrong.
94
+ *
95
+ * `payload` and `error` are kept as they are, and can hold private data: cut
96
+ * what you send. An event has no time, and a `parentId` can name an event that
97
+ * was pushed out: read it as a root.
89
98
  *
90
99
  * @example
91
100
  * ```ts
@@ -95,9 +104,15 @@ type Recorder<E = Record<string, unknown>> = TraceLogger<E> & {
95
104
  * bus.emit("search", "re");
96
105
  * bus.emit("search", "redux");
97
106
  * recorder.events.map((e) => e.phase); // ["start", "cancel", "start", "done"]
107
+ *
108
+ * const lastEvents = createTraceRecorder<Events>({ capacity: 500 });
98
109
  * ```
99
110
  */
100
- export declare const createTraceRecorder: <E = Record<string, unknown>>() => Recorder<E>;
111
+ export declare const createTraceRecorder: <E = Record<string, unknown>>(
112
+ /** `capacity`: keep the last N events (a ring buffer); not a positive integer, or left out: keep all. */
113
+ options?: {
114
+ capacity?: number;
115
+ }) => Recorder<E>;
101
116
  //#endregion
102
117
  //#region src/trace/redux.d.ts
103
118
  type ReduxConnection = {
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-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
+ import{n as e,t}from"./onAbort-BgMfspK4.js";import{a as n,c as r,n as i,o as a,r as o,s}from"./context-DtUnzC-1.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:r}=e;if(!r){e.emit(t);return}let{traceId:a,traceGroupId:o}=n,{ctx:c,unlink:l}=i({event:{type:a??t.type,payload:t,...o===void 0?{}:{traceGroupId:o}},bus:{emit:e.emit,on:e.on,off:e.off,ctx:e.ctx,addContext:e.addContext},tracer:r});s(c,`run`,`start`);try{c.emit(t),s(c,`run`,`done`)}catch(e){throw s(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=()=>e=>{if(!(r&&e.trace))return e;let{trace:t}=e,i=(e,n)=>{let r=t.task({type:_(e),payload:v(e,n)},o());t.report(r,`emit`,`done`)},s=e.emit;e.emit=(e,...t)=>h(e,()=>(i(e,t),s(e,...t))),Reflect.set(e,l,(t,n)=>{g(e,t,n)});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=(e,t)=>(r,i)=>(n(i,t),a(i,`run`,()=>e(r,i))),p=(e,t)=>(n,r,i)=>{let a=Reflect.get(Object(i),`traceId`);if(typeof a!=`string`)return e(n,r,i);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,i);return()=>{s()}},m=e.off,y=(e,t)=>{t===void 0?m(e):m(e,d(e,t)??t)},b=e.on,x=e.once;return e.on=p(b,!1),e.once=p(x,!0),e.off=y,e},b=e=>{let t=e?.capacity??0,n=Number.isInteger(t)&&t>0?t:0,r=[],i=0,a=e=>{n===0?r.push(e):(r[i%n]=e,i+=1)},o=()=>{r.length=0,i=0},s=()=>{let e=Math.min(i,n),t=i>n?i%n:0,a=[];for(let i=0;i<e;i+=1){let e=r[(t+i)%n];e&&a.push(e)}return a},c=Object.assign(a,{clear:o,events:r});return n>0&&Object.defineProperty(c,"events",{get:s}),c},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=t=>{let n=typeof t==`function`?[t]:[...t??[]];return n.length===0?null:t=>{for(let r of n)try{r(t)}catch(t){e(`trace logger failed`,t)}}},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,n={})=>{let r=D(e),{filter:i,sample:a=1}=n,o=0,s=e=>typeof a==`function`?a({type:e.type,payload:e.payload}):Math.random()<a,l={task:(e,t)=>({id:++o,root:t?.root??o,parent:t?.id,type:e.type,payload:e.payload,sampled:t?.sampled??s(e),group:k(e)??t?.group}),rename:(e,t)=>{e.type=t},report:(e,t,n,a)=>{if(!e.sampled)return;let o={id:e.id,type:e.type,effect:t,phase:n,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),n===`error`&&(o.error=a),(!i||i(o))&&r?.(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,n,r,i)=>{if(!e.sampled)return i();let a=l.settler(e,n);l.report(e,n,`start`),t({signal:r},()=>a(`cancel`));let o;try{o=i()}catch(e){throw a(`error`,e),e}return c(o)?Promise.resolve(o).then(e=>(a(`done`),e),e=>{throw a(`error`,e),e}):(a(`done`),o)}};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
- import { H as EventMap, c as Scope, l as WithFields, p as Effects } from "./action-creator-D_c223C7.js";
2
- import { t as BoundOf } from "./plugin-vvkmIIz2.js";
1
+ import { H as EventMap, c as Scope, l as WithFields, p as Effects } from "./action-creator-jeEqIKgK.js";
2
+ import { t as BoundOf } from "./plugin-BaErevLy.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
package/dist/vue.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { D as EventKey, H as EventMap, a as FreeActionCreator, t as ActionCreator, v as Bus } from "./action-creator-D_c223C7.js";
1
+ import { H as EventMap, T as EventKey, a as FreeActionCreator, t as ActionCreator, v as Bus } from "./action-creator-jeEqIKgK.js";
2
2
  import { t as OriginOptions } from "./origin-key-CxK0SkeG.js";
3
3
  import { t as StateOf } from "./state-of-C19H6BoV.js";
4
4
  import { ShallowRef, WatchSource, WatchStopHandle } from "vue";
@@ -1,4 +1,4 @@
1
- import { G as PayloadOf, H as EventMap, q as Task } from "./action-creator-D_c223C7.js";
1
+ import { G as PayloadOf, H as EventMap, q as Task } from "./action-creator-jeEqIKgK.js";
2
2
  //#region src/policies/with-policy.d.ts
3
3
  /** What `withPolicy` returns: the task as a plain function that can be cancelled. */
4
4
  type PolicyFunction<P, R> = {
@@ -1 +1 @@
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};
1
+ import{n as e}from"./context-DtUnzC-1.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
@@ -8,6 +8,18 @@ Why the library exists (logic and side effects outside React, Vue and Svelte com
8
8
 
9
9
  Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-checked and tested).
10
10
 
11
+ ## Start with three imports
12
+
13
+ Most apps need only these. Everything else is optional; read it when you hit its need.
14
+
15
+ | Import | Gives you |
16
+ | ------ | --------- |
17
+ | `nanoeffects` | `createBus`: `on`, `emit`, `ctx` |
18
+ | `nanoeffects/policies` | `latest`, `debounce`, ... for races in async UI |
19
+ | `nanoeffects/effects` | `retry`, `timeout`, `poll`, ... for an unreliable network |
20
+
21
+ The other entries and plugins: [Package entries](package.md) and [Plugins](plugins.md#pick-a-plugin-by-task).
22
+
11
23
  ## Jump by project need
12
24
 
13
25
  | Project shape | First page |
@@ -19,6 +31,7 @@ Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-che
19
31
  | Already on Redux / Zustand / MobX / Pinia | [Stores](stores.md) |
20
32
  | A Node server (Fastify) whose work must stop when the client leaves | [Stop work on a server](server.md) |
21
33
  | A React form with async checks | [Build a sign-up form in React](react.md) |
34
+ | A site widget that listens only while open | [Build a chat widget](chat-widget.md) |
22
35
  | A Vue app | [Use nanoeffects in a Vue app](vue.md) |
23
36
  | A Svelte app | [Use nanoeffects in a Svelte app](svelte.md) |
24
37
  | Old responses overwrite new ones | [Cancel stale requests](cancel-stale-requests.md) |
@@ -39,7 +52,7 @@ Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-che
39
52
  - [nanoeffects in a Svelte app](svelte.md) — `eventStore`
40
53
  - [Context fields](context.md) — store or client on every task
41
54
  - [Use the bus from a class](oop.md) — `extendBus`, `@WithBus`, `@On`, and `dispose()` for the subscriptions of an instance
42
- - [TypeScript in many files](typescript.md) — keep the types of the bus in commands and modules
55
+ - [TypeScript in many files](typescript.md) — keep the types of the bus in actions and modules
43
56
  - [Errors and unheard events](errors.md)
44
57
  - [Package entries](package.md) — which import holds what
45
58
  - [Stability](stability.md) — which entries are stable, what is breaking, supported versions
package/docs/actions.md CHANGED
@@ -10,6 +10,32 @@ A chat with two entities and three features, jobs under policies and effects, an
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
 
13
+ ## Pick the word: event, action, job, module
14
+
15
+ Four names, one idea each. Take the first row that fits.
16
+
17
+ | Word | What it is | Use it when |
18
+ | ---- | ---------- | ----------- |
19
+ | Event | A name and a payload; `createAction("x")` with no handler. | The UI or a handler tells that something happened. |
20
+ | Action with a handler | The same, plus work: `createAction("x", handler)`. `emit` returns the result. | The caller needs a value back. |
21
+ | Job | An action with a handler that also emits `pending`, `success`, `fail` and `cancel`. | The UI shows a loading state. |
22
+ | Module | A group of listeners, handlers and tasks, mounted and unmounted with one call. | One domain must start and stop as a unit. |
23
+
24
+ Start with events. Add a handler when you need a result, a job when you need a status, a module when you need a unit.
25
+
26
+ ## On this page
27
+
28
+ - [Pick the word: event, action, job, module](#pick-the-word-event-action-job-module)
29
+ - [Return a value from `emit` with a handler](#return-a-value-from-emit-with-a-handler)
30
+ - [Describe events, handlers and jobs](#describe-events-handlers-and-jobs)
31
+ - [Actions](#actions)
32
+ - [Bind a handler later](#bind-a-handler-later)
33
+ - [Say what a bound handler returns](#say-what-a-bound-handler-returns)
34
+ - [Jobs](#jobs)
35
+ - [Show a loading state with a job](#show-a-loading-state-with-a-job)
36
+ - [Mount domains with modules](#mount-domains-with-modules)
37
+ - [Give an action or a module its own fields](#give-an-action-or-a-module-its-own-fields)
38
+
13
39
  ## Return a value from `emit` with a handler
14
40
 
15
41
  Give `createAction` a handler, and `emit` runs it and returns its result. There is one creator for an event and for an action with work behind it; the handler is the only difference.
@@ -164,7 +190,7 @@ On a bus without an event map you can say it where you call: `const id = bus.emi
164
190
  - `pending` / `success` / `fail` / `cancel` are a string (job makes an action creator) or an existing action creator. Missing ones become `<type>/pending`, `<type>/success`, `<type>/fail` or `<type>/cancel`.
165
191
  - `bus.emit(loadUser(id))` returns a promise of the result. Handler errors are always handled: they go to `fail` and the promise resolves with `undefined`. For a result that must reject, give `createAction` the handler.
166
192
  - Outcome actions are emitted as the task that ran the job (also for a handler bound with `actions.handle`). `success` and `fail` are not sent after abort; `cancel` is, at the moment of the abort.
167
- - A job without a handler is not an outcome: `emit` throws `No handler for command "..."` and nothing is sent. An action without a handler is an event and does not throw.
193
+ - A job without a handler is not an outcome: `emit` throws `No handler for action "..."` and nothing is sent. An action without a handler is an event and does not throw.
168
194
  - Calling the job only builds `{ type, payload }`; the handler runs on `emit`.
169
195
  - The outcomes are events like any other: with `onUnhandled` set on the bus, it is called for a `pending` or a `cancel` that nobody listens to.
170
196
 
@@ -326,7 +352,7 @@ If the `setup` of one module throws, the modules of that call that were already
326
352
  - **A module is known by its name.** Mounting a module whose name is taken replaces the mounted one (see [Hot reload](hmr.md)).
327
353
  - **Registry.** If `bus.use(actionsPlugin())` ran first, that registry is used; otherwise the module plugin attaches its own. Either way it is `bus.actions` and `app.actions`.
328
354
  - A disposed app cannot mount modules.
329
- - **The given modules are mounted at the end of the block that made the app**, or at the first use of the app or the bus if that comes first (the first `bus.emit`, or a call of `mount`, `unmount`, `isMounted`, `dispose`, or the read of `commands`). Nothing needs to use the app: `bus.use(modulePlugin([a, b]))` is enough to start them. The plugins that you use after `modulePlugin` in the same block still reach them, because the modules wait until that block has finished. A plugin used after an `await` can come too late: use every plugin before the first `await`. The `bus.on` calls made in between are held and made right after, so the listeners of a module stay ahead of them. See [using plugins in any order](plugins.md#use-them-in-any-order).
355
+ - **The given modules are mounted at the end of the block that made the app**, or at the first use of the app or the bus if that comes first (the first `bus.emit`, or a call of `mount`, `unmount`, `isMounted`, `dispose`, or the read of `actions`). Nothing needs to use the app: `bus.use(modulePlugin([a, b]))` is enough to start them. The plugins that you use after `modulePlugin` in the same block still reach them, because the modules wait until that block has finished. A plugin used after an `await` can come too late: use every plugin before the first `await`. The `bus.on` calls made in between are held and made right after, so the listeners of a module stay ahead of them. See [using plugins in any order](plugins.md#use-them-in-any-order).
330
356
  - **To mount by hand instead**, pass no modules: `modulePlugin()` starts nothing, and `app.mount(...)` starts the modules when you want.
331
357
  - Module context = task context plus `handle`, `use`, and `spawn`:
332
358
  - `handle` — give an action a handler (one that has none, or an override).
@@ -14,6 +14,18 @@ npm run dev
14
14
 
15
15
  `npm run dev` starts the server and Vite together. Start it with `CHAOS=off npm run dev` to turn the failures off.
16
16
 
17
+ ## On this page
18
+
19
+ - [Read how the server fails](#read-how-the-server-fails)
20
+ - [See how the files are split](#see-how-the-files-are-split)
21
+ - [Load a history that must not race](#load-a-history-that-must-not-race)
22
+ - [Send a message that can fail](#send-a-message-that-can-fail)
23
+ - [Search where one room is slow](#search-where-one-room-is-slow)
24
+ - [Wait for a room before marking a result](#wait-for-a-room-before-marking-a-result)
25
+ - [Poll for new messages](#poll-for-new-messages)
26
+ - [Know where each value lives](#know-where-each-value-lives)
27
+ - [Where to go next](#where-to-go-next)
28
+
17
29
  ## Read how the server fails
18
30
 
19
31
  The server keeps its data in memory. These rules are on purpose, and they are the same every run, so you can follow each section below by doing what the table says:
@@ -48,7 +60,7 @@ The code is in layers, and a file imports only from the layers below it. The com
48
60
 
49
61
  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
62
 
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` with no handler) says that something happened, and any feature may listen: `rooms/selected` is heard by two. A command (`createAction` with a handler) is an order with exactly one handler, as `search/openResult` and `messages/mark` are. The handler is given to `createAction`, 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.
63
+ 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 actions, never through its handlers or its store. All actions live in the events file. An action with no handler says that something happened, and any feature may listen: `rooms/selected` is heard by two. An action with a handler is an order with exactly one handler, as `search/openResult` and `messages/mark` are. The handler is given to `createAction`, so the action 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 an action with a handler 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
64
 
53
65
  The layers come from [Feature-Sliced Design](https://feature-sliced.design/), reduced to the three that an app of this size needs.
54
66
 
@@ -64,7 +76,7 @@ A job sends `pending` when its handler starts and exactly one of `success`, `fai
64
76
  // message.js
65
77
  import { createJob } from "nanoeffects/actions";
66
78
 
67
- // createJob — a command that also sends pending, success, fail and cancel
79
+ // createJob — an action with a handler that also sends pending, success, fail and cancel
68
80
  export const loadHistory = createJob("messages/loadHistory");
69
81
  ```
70
82
 
@@ -195,7 +207,7 @@ export const goToResult = async (hit, ctx) => {
195
207
  ctx.emit("rooms/selected", hit.roomId);
196
208
  await loaded;
197
209
  }
198
- // ctx.emit with a command — the message entity marks the message
210
+ // ctx.emit with an action that has a handler — the message entity marks the message
199
211
  ctx.emit(markMessage(hit.id));
200
212
  };
201
213
  ```
@@ -247,6 +259,6 @@ Each entity and each feature that has state keeps it in a [Zustand](https://zust
247
259
 
248
260
  ## Where to go next
249
261
 
250
- - The API of commands, jobs and modules: [Actions, jobs and modules](actions.md).
262
+ - The API of actions, jobs and modules: [Actions, jobs and modules](actions.md).
251
263
  - Policies such as `latest` and `queue`: [Policies](policies.md). Effects such as `allSettled`, `take` and `poll`: [Effects](effects.md).
252
264
  - 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.
@@ -0,0 +1,135 @@
1
+ # Build a chat widget that listens only while it is open
2
+
3
+ Use this page when a site has a chat with a bot behind a launcher button, and the chat must cost nothing while it is closed. Closed means no subscriptions on the bus, no polling, no keyboard listener, and no request still running. Opening turns all of it on, and closing turns all of it off in one call.
4
+
5
+ The code is in [`examples/chat-widget`](../examples/chat-widget): plain DOM and TypeScript, a fake bot in the browser, no server. Run it on [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat-widget), or on your machine:
6
+
7
+ ```bash
8
+ cd examples/chat-widget
9
+ npm install
10
+ npm run dev
11
+ ```
12
+
13
+ It is a level D example: the logic lives in one module, and the page only mounts and unmounts it. For an app with several features see [Build a chat with entities, features, modules and a small server](chat-example.md).
14
+
15
+ ## Know what happens on open and on close
16
+
17
+ | Moment | What happens |
18
+ | ------ | ------------ |
19
+ | Open | The module is mounted: the bot handler is subscribed, the operator messages are polled, the `Escape` listener is added. The panel is drawn and listens to the bus. |
20
+ | The user sends a text | The bot is asked, one text after another, with a time limit and a retry. |
21
+ | `Escape` | The module emits `chat/closeRequested`; the launcher closes the chat. |
22
+ | Close | The panel is removed and unsubscribed, the module is unmounted. A request in flight is aborted, the polling stops, the `Escape` listener goes away. |
23
+
24
+ ## Find the parts in the files
25
+
26
+ | File | Job |
27
+ | ---- | --- |
28
+ | `src/bus.ts` | The bus, and `modulePlugin()` with no module: the launcher mounts the module itself. |
29
+ | `src/chat.ts` | The events only: `messageSent`, `messageAdded`, `typingChanged`, `closeRequested`. |
30
+ | `src/chat-handlers.ts` | `askBot` (`timeout` and `retry` inside) and `watchOperator` (`poll`): plain `async` functions that know nothing about the page. |
31
+ | `src/chat-module.ts` | The list of what is alive while the chat is open. |
32
+ | `src/panel.ts` | The view: shows the lines, sends the texts, returns one function that removes it. |
33
+ | `src/main.ts` | The launcher: the only file that knows both the module and the panel; it listens for `closeRequested` only while the chat is open. |
34
+
35
+ ## Bind everything in one module
36
+
37
+ A module is the list of what is alive while the chat is open. When it unmounts, the bus undoes every line of it.
38
+
39
+ <!-- docs-check: skip - the other files of the example are in examples/chat-widget -->
40
+ ```js
41
+ // chat-module.js
42
+ import { createModule } from "nanoeffects/actions";
43
+ import { queue } from "nanoeffects/policies";
44
+ import { closeRequested, messageSent } from "./chat";
45
+ import { askBot, watchOperator } from "./chat-handlers";
46
+
47
+ // a task that stays pending until the module unmounts; its signal then aborts
48
+ const untilAborted = (ctx) =>
49
+ new Promise((resolve) => {
50
+ ctx.signal.addEventListener("abort", resolve, { once: true });
51
+ });
52
+
53
+ // a DOM listener that lives as long as the module: `signal` removes it when it aborts
54
+ const listenToEscape = async (_, ctx) => {
55
+ const onKey = (event) => {
56
+ if (event.key === "Escape") {
57
+ ctx.emit(closeRequested());
58
+ }
59
+ };
60
+ document.addEventListener("keydown", onKey, { signal: ctx.signal });
61
+ await untilAborted(ctx);
62
+ };
63
+
64
+ export const chatModule = createModule("chat", (module) => {
65
+ // module.run — runs askBot as a child task of the module, so an unmount aborts it
66
+ const answer = (text) => module.run(askBot, text);
67
+ // on + queue — answer the texts one after another, in the order they were sent
68
+ module.on(messageSent, queue(answer));
69
+ // spawn — a background task that lives as long as the module
70
+ module.spawn(watchOperator);
71
+ module.spawn(listenToEscape);
72
+ });
73
+ ```
74
+
75
+ Three details that make the cleanup complete:
76
+
77
+ - **`module.run`, not `askBot` directly.** A handler given to `module.on` runs as a child of whoever emitted the event (here the panel), so unmounting stops listening but does not abort a request that is already running. Running it with `module.run` makes it a child of the module.
78
+ - **`queue`, not `latest`.** `queue` keeps the order when the user sends two texts fast. [`latest`](policies.md) would abort the first answer, which is wrong for a conversation.
79
+ - **A DOM listener with `signal`.** `addEventListener` takes a `signal` option and removes the listener when it aborts. The task stays pending until then, because a task that has finished is not aborted again.
80
+
81
+ ## Open and close from the launcher
82
+
83
+ `app.mount` returns the function that unmounts the module. `bus.on` returns the one that unsubscribes. The launcher puts them together with the one that removes the panel into a single `close`.
84
+
85
+ <!-- docs-check: skip - the other files of the example are in examples/chat-widget -->
86
+ ```js
87
+ // main.js
88
+ import { app, bus } from "./bus";
89
+ import { closeRequested } from "./chat";
90
+ import { chatModule } from "./chat-module";
91
+ import { renderPanel } from "./panel";
92
+
93
+ const button = document.querySelector("#chat-launcher");
94
+ let close = null; // set while the chat is open
95
+
96
+ const open = () => {
97
+ const stopModule = app.mount(chatModule); // subscriptions, polling and Escape start
98
+ const stopPanel = renderPanel(document.body);
99
+ // the module emits closeRequested on Escape; nothing does while the chat is closed
100
+ const stopEscape = bus.on(closeRequested, () => close?.());
101
+ close = () => {
102
+ stopEscape();
103
+ stopPanel();
104
+ stopModule(); // everything the module started ends, a running request included
105
+ close = null;
106
+ };
107
+ };
108
+
109
+ button.addEventListener("click", () => {
110
+ if (close) {
111
+ close();
112
+ } else {
113
+ open();
114
+ }
115
+ });
116
+ ```
117
+
118
+ While the chat is closed the launcher holds no subscription at all: it only has the click listener of the button, which is a DOM listener, not a listener of the bus.
119
+
120
+ ## Check that closing cleans up
121
+
122
+ 1. Open the chat and send a text. The bot answers after about a second. Every third question fails, and the retry asks again.
123
+ 2. Send a text and close the chat before the answer arrives. No line appears, also when you open the chat again.
124
+ 3. Look at the box "Listeners on the bus": it shows `bus.get(event).length` for four events and updates when the chat opens or closes. Closed: every event shows `0`, and so does "all events". Open: each of the four has one listener, and "all events" is `4`. That is the proof that nothing is left subscribed.
125
+ 4. Wait 10 s with the chat closed. The operator is not polled: no line from the operator appears.
126
+ 5. Press `Escape` with the chat closed. Nothing happens. Open the chat and press it again: it closes.
127
+ 6. Open the chat again. The old lines are back, and the polling runs.
128
+
129
+ `src/tests/chat-widget-example.test.ts` checks the same points without a browser.
130
+
131
+ ## Where to go next
132
+
133
+ - Show an unread count on the launcher while the chat is closed: a second module that stays mounted and listens only to `chat/messageAdded`.
134
+ - Replace the polling by a WebSocket in a spawned task: the abort closes it the same way (see [`examples/trades`](../examples/trades) for a socket under `retry` and `latest`).
135
+ - Watch the whole thing in the [trace](tracing.md): the module, the bot task, the retries and the polling are tasks of one tree.
package/docs/context.md CHANGED
@@ -21,7 +21,7 @@ Library fields (`signal`, `emit`, `run`, …) win on name clashes. `context` may
21
21
 
22
22
  To get `race`, `all`, `take` and the like on every task without passing `ctx`, see [`effectsPlugin`](effects.md#put-selected-effects-on-ctx).
23
23
 
24
- To give fields to **one** command, action, job or module, or to replace them for one `emit`, see [scopes](scope.md).
24
+ To give fields to **one** action, job or module, or to replace them for one `emit`, see [scopes](scope.md).
25
25
 
26
26
  ## Type the fields (optional)
27
27
 
@@ -45,6 +45,38 @@ bus.on("save", (draft, { store }) => store.put(draft));
45
45
  - A name that the bus already has is a type error. The fields of the library (`signal`, `emit`, `run`, …) win at runtime, and a field that the context already has is not replaced.
46
46
  - The plugin returns the same bus, so keep the result of the chain: `const bus = createBus().use(contextPlugin({ store }))`.
47
47
 
48
+ ## Build a field from the signal of each task
49
+
50
+ Problem: an HTTP client or a database handle should stop when the task is aborted. Without help, every handler passes `ctx.signal` to it by hand, and one forgotten call leaves a request running.
51
+
52
+ `fromSignal(make)` from `nanoeffects/signal` marks a field of `contextPlugin` as a value that is built from the signal of the task. It does not run `make` for the bus: it runs it for each task that reads the field, once, with the `ctx.signal` of that task.
53
+
54
+ ```js
55
+ import { createBus } from "nanoeffects";
56
+ import { contextPlugin } from "nanoeffects/context";
57
+ import { fromSignal } from "nanoeffects/signal";
58
+ import axios from "axios";
59
+
60
+ // contextPlugin — every task gets these fields
61
+ // fromSignal — http is made from the signal of the task that reads ctx.http
62
+ const bus = createBus().use(
63
+ contextPlugin({
64
+ logger: console, // a plain field: the same object for every task
65
+ http: fromSignal((signal) => axios.create({ signal })),
66
+ }),
67
+ );
68
+
69
+ bus.on("user/open", async (id, ctx) => {
70
+ const response = await ctx.http.get(`/users/${id}`); // aborts with the task
71
+ ctx.logger.log(response.data);
72
+ });
73
+ ```
74
+
75
+ - `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
76
+ - A task that `ctx.run` starts builds its own value from its own signal, so aborting the child stops only the child's requests.
77
+ - The field is not copied by `{ ...ctx }` and `Object.keys(ctx)` does not list it. Read `ctx.http` first if you copy a context.
78
+ - Without `contextPlugin` a `fromSignal` marker is only an object.
79
+
48
80
  ### Name the fields in the event map instead
49
81
 
50
82
  `createBus<Events>({ context: { store } })` also types the fields, but through the event map: `WithContext` is checker-only and says which fields the handlers of this bus have. Use it when you cannot call `use`, for example for the bus that `createEffectsMiddleware` makes.