├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── LICENSE
├── README.md
├── package.json
├── src
├── Actor.ts
├── ActorRef.ts
├── ActorSystem.ts
├── Behavior.ts
├── Topic.ts
├── index.ts
├── observable.ts
└── types.ts
├── test
├── actor.test.ts
├── actorSystem.test.ts
├── behaviors.test.ts
├── diningHakkers.test.ts
├── samples.ts
└── todo.test.ts
├── tsconfig.json
└── yarn.lock
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: [push]
3 | jobs:
4 | build:
5 | runs-on: ubuntu-latest
6 |
7 | steps:
8 | - name: Begin CI...
9 | uses: actions/checkout@v2
10 |
11 | - name: Use Node 12
12 | uses: actions/setup-node@v1
13 | with:
14 | node-version: 12.x
15 |
16 | - name: Use cached node_modules
17 | uses: actions/cache@v1
18 | with:
19 | path: node_modules
20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }}
21 | restore-keys: |
22 | nodeModules-
23 |
24 | - name: Install dependencies
25 | run: yarn install --frozen-lockfile
26 | env:
27 | CI: true
28 |
29 | - name: Lint
30 | run: yarn lint
31 | env:
32 | CI: true
33 |
34 | - name: Test
35 | run: yarn test --ci --coverage --maxWorkers=2
36 | env:
37 | CI: true
38 |
39 | - name: Build
40 | run: yarn build
41 | env:
42 | CI: true
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | # Logs
3 | logs
4 | *.log
5 | npm-debug.log*
6 | yarn-debug.log*
7 | yarn-error.log*
8 | lerna-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Microbundle cache
58 | .rpt2_cache/
59 | .rts2_cache_cjs/
60 | .rts2_cache_es/
61 | .rts2_cache_umd/
62 |
63 | # Optional REPL history
64 | .node_repl_history
65 |
66 | # Output of 'npm pack'
67 | *.tgz
68 |
69 | # Yarn Integrity file
70 | .yarn-integrity
71 |
72 | # dotenv environment variables file
73 | .env
74 | .env.test
75 |
76 | # parcel-bundler cache (https://parceljs.org/)
77 | .cache
78 | .parcel-cache
79 |
80 | # Next.js build output
81 | .next
82 | out
83 |
84 | # Nuxt.js build / generate output
85 | .nuxt
86 | dist
87 |
88 | # Gatsby files
89 | .cache/
90 | # Comment in the public line in if your project uses Gatsby and not Next.js
91 | # https://nextjs.org/blog/next-9-1#public-directory-support
92 | # public
93 |
94 | # vuepress build output
95 | .vuepress/dist
96 |
97 | # Serverless directories
98 | .serverless/
99 |
100 | # FuseBox cache
101 | .fusebox/
102 |
103 | # DynamoDB Local files
104 | .dynamodb/
105 |
106 | # TernJS port file
107 | .tern-port
108 |
109 | # Stores VSCode versions used for testing VSCode extensions
110 | .vscode-test
111 |
112 | # yarn v2
113 |
114 | .yarn/cache
115 | .yarn/unplugged
116 | .yarn/build-state.yml
117 | .pnp.*
118 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 David Khourshid
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | The Actor Model for JavaScript
6 |
7 |
8 |
9 |
10 | > [!IMPORTANT]
11 | > This library is no longer maintained. Please see [XState v5](https://github.com/statelyai/xstate), which now has first-class support for the actor model.
12 |
13 |
14 | XActor is an [actor model](https://en.wikipedia.org/wiki/Actor_model) implementation for JavaScript and TypeScript, heavily inspired by [Akka](https://akka.io/). It represents the "actor model" parts of [XState](https://github.com/davidkpiano/xstate) and can be used with or without XState.
15 |
16 | ## Resources
17 |
18 | Learn more about the Actor Model:
19 |
20 | - [The Actor Model in 10 Minutes](https://www.brianstorti.com/the-actor-model/)
21 | - [🎥 The Actor Model Explained](https://www.youtube.com/watch?v=ELwEdb_pD0k)
22 | - [What is the Actor Model and When Should You Use It?](https://mattferderer.com/what-is-the-actor-model-and-when-should-you-use-it)
23 | - [📄 ACTORS: A Model of Concurrent Computation in Distributed Systems (Gul Agha)](https://dspace.mit.edu/handle/1721.1/6952)
24 | - [📄 A Universal Modular ACTOR Formalism for Artificial Intelligence (Carl Hewitt et. al.](https://www.semanticscholar.org/paper/A-Universal-Modular-ACTOR-Formalism-for-Artificial-Hewitt-Bishop/acb2f7040e21cbe456030c8535bc3f2aafe83b02)
25 |
26 |
27 | ## Installation
28 |
29 | - With [npm](https://www.npmjs.com/package/xactor): `npm install xactor --save`
30 | - With Yarn: `yarn add xactor`
31 |
32 | ## Quick Start
33 |
34 | [Simple Example](https://codesandbox.io/s/simple-xactor-example-7iyck?file=/src/index.js):
35 | ```js
36 | import { createSystem, createBehavior } from 'xactor';
37 |
38 | // Yes, I know, another trivial counter example
39 | const counter = createBehavior(
40 | (state, message, context) => {
41 | if (message.type === 'add') {
42 | context.log(`adding ${message.value}`);
43 |
44 | return {
45 | ...state,
46 | count: state.count + message.value,
47 | };
48 | }
49 |
50 | return state;
51 | },
52 | { count: 0 }
53 | );
54 |
55 | const counterSystem = createSystem(counter, 'counter');
56 |
57 | counterSystem.subscribe(state => {
58 | console.log(state);
59 | });
60 |
61 | counterSystem.send({ type: 'add', value: 3 });
62 | // => [counter] adding 3
63 | // => { count: 3 }
64 | counterSystem.send({ type: 'add', value: 1 });
65 | // => [counter] adding 1
66 | // => { count: 4 }
67 | ```
68 |
69 | ## API
70 |
71 | ### `createBehavior(reducer, initialState)`
72 |
73 | Creates a **behavior** that is represented by the `reducer` and starts at the `initialState`.
74 |
75 | **Arguments**
76 |
77 | - `reducer` - a reducer that takes 3 arguments (`state`, `message`, `actorContext`) and should return the next state (tagged or not).
78 | - `initialState` - the initial state of the behavior.
79 |
80 | **Reducer Arguments**
81 |
82 | - `state` - the current _untagged_ state.
83 | - `message` - the current message to be processed by the reducer.
84 | - `actorContext` - the [actor context](#actor-context) of the actor instance using this behavior.
85 |
86 | ## Actor Context
87 |
88 | The actor context is an object that includes contextual information about the current actor instance:
89 |
90 | - `self` - the `ActorRef` reference to the own actor
91 | - `system` - the reference to the actor system that owns this actor
92 | - `log` - function for logging messages that reference the actor
93 | - `spawn` - function to [spawn an actor](#spawning-actors)
94 | - `stop` - function to stop a spawned actor
95 | - `watch` - function to watch an actor
96 |
97 | ## Spawning Actors
98 |
99 | Actors can be spawned via `actorContext.spawn(behavior, name)` within a behavior reducer:
100 |
101 | ```js
102 | const createTodo = (content = "") => createBehavior((state, msg, ctx) => {
103 | // ...
104 |
105 | return state;
106 | }, { content });
107 |
108 | const todos = createBehavior((state, msg, ctx) => {
109 | if (msg.type === 'todo.create') {
110 | return {
111 | ...state,
112 | todos: [
113 | ...state.todos,
114 | ctx.spawn(createTodo(), 'some-unique-todo-id')
115 | ]
116 | }
117 | }
118 |
119 | // ...
120 |
121 | return state;
122 | }, { todos: [] });
123 |
124 | const todoSystem = createSystem(todos, 'todos');
125 |
126 | todoSystem.send({ type: 'todo.create' });
127 | ```
128 |
129 | _Documentation still a work-in-progress! Please see [the tests](https://github.com/davidkpiano/xactor/blob/master/test/actorSystem.test.ts) for now as examples._
130 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.3.1",
3 | "license": "MIT",
4 | "main": "dist/index.js",
5 | "typings": "dist/index.d.ts",
6 | "files": [
7 | "dist",
8 | "src"
9 | ],
10 | "engines": {
11 | "node": ">=10"
12 | },
13 | "scripts": {
14 | "start": "tsdx watch",
15 | "build": "tsdx build",
16 | "test": "tsdx test",
17 | "lint": "tsdx lint",
18 | "prepare": "tsdx build",
19 | "preversion": "npm run prepare"
20 | },
21 | "peerDependencies": {},
22 | "husky": {
23 | "hooks": {
24 | "pre-commit": "tsdx lint"
25 | }
26 | },
27 | "prettier": {
28 | "printWidth": 80,
29 | "semi": true,
30 | "singleQuote": true,
31 | "trailingComma": "es5"
32 | },
33 | "name": "xactor",
34 | "author": "David Khourshid",
35 | "module": "dist/xactor.esm.js",
36 | "devDependencies": {
37 | "@types/node": "^14.11.1",
38 | "husky": "^4.2.5",
39 | "rxjs": "^6.6.3",
40 | "tsdx": "^0.14.1",
41 | "tslib": "^2.0.0",
42 | "typescript": "^4.3.2"
43 | },
44 | "resolutions": {
45 | "**/@typescript-eslint/eslint-plugin": "^4.11.1",
46 | "**/@typescript-eslint/parser": "^4.11.1",
47 | "**/jest": "^26.6.3",
48 | "**/ts-jest": "^26.4.4",
49 | "**/typescript": "^4.1.3"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Actor.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ActorContext,
3 | ActorSignal,
4 | ActorSignalType,
5 | Behavior,
6 | BehaviorTag,
7 | TaggedState,
8 | Observer,
9 | SubscribableByObserver,
10 | Subscribable,
11 | EventObject,
12 | } from './types';
13 | import { XActorRef } from './ActorRef';
14 | import { ActorSystem } from './ActorSystem';
15 | import { fromEntity } from './Behavior';
16 |
17 | enum ActorRefStatus {
18 | Deferred = -1,
19 | Idle = 0,
20 | Processing = 1,
21 | }
22 |
23 | export type Listener = (emitted: T) => void;
24 |
25 | export class Actor
26 | implements SubscribableByObserver {
27 | private actorContext: ActorContext;
28 | private children = new Set>();
29 | private mailbox: TEvent[] = [];
30 | private status: ActorRefStatus = ActorRefStatus.Deferred;
31 | private reducer: Behavior[0];
32 | private taggedState: TaggedState;
33 |
34 | // same as `watching` in Scala ActorRef
35 | private topics = {
36 | watchers: new Set>(),
37 | listeners: new Set>(),
38 | errorListeners: new Set>(),
39 | };
40 |
41 | constructor(
42 | behavior: Behavior,
43 | public name: string,
44 | ref: XActorRef,
45 | private system: ActorSystem
46 | ) {
47 | [this.reducer, this.taggedState] = behavior;
48 | const logger = this.system.logger(ref);
49 |
50 | this.actorContext = {
51 | self: ref,
52 | system: this.system,
53 | log: (logMessage: string) => {
54 | this.system.logs.push({
55 | ref,
56 | log: logMessage,
57 | });
58 |
59 | logger(logMessage);
60 | },
61 | children: this.children,
62 | spawn: this.spawn.bind(this),
63 | spawnFrom: (
64 | getPromise: () => Promise | Subscribable,
65 | name: string
66 | ) => {
67 | const sendToSelf = (value: U) => {
68 | ref.send(value);
69 | };
70 |
71 | return this.spawn(
72 | fromEntity(getPromise, {
73 | next: sendToSelf,
74 | error: sendToSelf,
75 | complete: undefined,
76 | }),
77 | name
78 | );
79 | },
80 | send: (actorRef, message) => {
81 | this.system.logs.push({
82 | from: ref,
83 | to: actorRef,
84 | message,
85 | });
86 |
87 | actorRef.send(message);
88 | },
89 | stop: (child: XActorRef): void => {
90 | child.signal({ type: ActorSignalType.PostStop });
91 | this.children.delete(child);
92 | },
93 | subscribeTo: (topic: 'watchers', subscriberRef: XActorRef) => {
94 | this.topics[topic].add(subscriberRef);
95 | },
96 | watch: actorRef => {
97 | actorRef.signal({
98 | type: ActorSignalType.Watch,
99 | ref,
100 | });
101 | },
102 | };
103 |
104 | // Don't start immediately
105 | // TODO: add as config option to start immediately?
106 | // this.start();
107 | }
108 |
109 | public start(): void {
110 | this.status = ActorRefStatus.Idle;
111 | const initialTaggedState = this.reducer(
112 | this.taggedState,
113 | { type: ActorSignalType.Start },
114 | this.actorContext
115 | );
116 | this.update(initialTaggedState);
117 | this.flush();
118 | }
119 |
120 | public receive(message: TEvent): void {
121 | this.mailbox.push(message);
122 | if (this.status === ActorRefStatus.Idle) {
123 | this.flush();
124 | }
125 | }
126 | public receiveSignal(signal: ActorSignal): void {
127 | if (signal.type === ActorSignalType.Watch) {
128 | this.topics.watchers.add(signal.ref);
129 | return;
130 | }
131 |
132 | const { state } = this.reducer(this.taggedState, signal, this.actorContext);
133 |
134 | this.taggedState = state;
135 | }
136 | private process(message: TEvent): void {
137 | if (this.taggedState.$$tag === BehaviorTag.Stopped) {
138 | console.warn(
139 | `Attempting to send message to stopped actor ${this.name}`,
140 | message
141 | );
142 | return;
143 | }
144 |
145 | this.status = ActorRefStatus.Processing;
146 |
147 | const nextTaggedState = this.reducer(
148 | this.taggedState,
149 | message,
150 | this.actorContext
151 | );
152 |
153 | this.update(nextTaggedState);
154 |
155 | this.status = ActorRefStatus.Idle;
156 | }
157 |
158 | private update(taggedState: TaggedState) {
159 | this.taggedState = taggedState;
160 |
161 | const { effects } = taggedState;
162 | effects.forEach(effect => {
163 | if ('actor' in effect) {
164 | (effect.actor as any).start();
165 | }
166 | });
167 |
168 | this.topics.listeners.forEach(listener => {
169 | listener(taggedState.state);
170 | });
171 |
172 | if (taggedState.$$tag === BehaviorTag.Stopped) {
173 | this.stop();
174 | }
175 | }
176 | private stop() {
177 | this.actorContext.children.forEach(child => {
178 | this.actorContext.stop(child);
179 | });
180 | this.receiveSignal({ type: ActorSignalType.PostStop });
181 | this.topics.watchers.forEach(watcher => {
182 | watcher.signal({
183 | type: ActorSignalType.Terminated,
184 | ref: this.actorContext.self,
185 | });
186 | });
187 | }
188 |
189 | private flush() {
190 | while (this.mailbox.length) {
191 | const message = this.mailbox.shift()!;
192 | this.process(message);
193 | }
194 | }
195 |
196 | private spawn(
197 | behavior: Behavior,
198 | name: string
199 | ): XActorRef {
200 | const child = new XActorRef(behavior, name, this.system);
201 | this.children.add(child);
202 | return child;
203 | }
204 |
205 | public subscribe(observer?: Observer | null) {
206 | if (!observer) {
207 | return { unsubscribe: () => {} };
208 | }
209 |
210 | // TODO: file an RxJS issue (should not need to be bound)
211 | if (observer.next) this.topics.listeners.add(observer.next.bind(observer));
212 | if (observer.error)
213 | this.topics.errorListeners.add(observer.error.bind(observer));
214 |
215 | return {
216 | unsubscribe: () => {
217 | if (observer.next) this.topics.listeners.delete(observer.next);
218 | if (observer.error) this.topics.errorListeners.delete(observer.error);
219 | },
220 | };
221 | }
222 |
223 | public getSnapshot(): TEmitted {
224 | return this.taggedState.state;
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/src/ActorRef.ts:
--------------------------------------------------------------------------------
1 | import { ActorSystem } from './ActorSystem';
2 | import { Actor, Listener } from './Actor';
3 | import {
4 | ActorSignal,
5 | Behavior,
6 | Subscribable,
7 | Observer,
8 | EventObject,
9 | } from './types';
10 | import { symbolObservable } from './observable';
11 |
12 | export interface BaseActorRef
13 | extends Subscribable {
14 | send: (event: T) => void;
15 | }
16 |
17 | export interface ActorRef
18 | extends Subscribable {
19 | send: (event: TEvent) => void;
20 | }
21 |
22 | export type ActorRefOf<
23 | TBehavior extends Behavior
24 | > = TBehavior extends Behavior
25 | ? XActorRef
26 | : never;
27 |
28 | function unhandledErrorListener(error: any) {
29 | console.error(error);
30 | }
31 |
32 | export class XActorRef
33 | implements Subscribable, ActorRef {
34 | private actor: Actor;
35 | // private system: ActorSystem;
36 | public name: string;
37 |
38 | constructor(
39 | behavior: Behavior,
40 | name: string,
41 | system: ActorSystem
42 | ) {
43 | this.name = name;
44 | this.actor = new Actor(behavior, name, this, system);
45 | this.send = this.send.bind(this);
46 | // this.system = system;
47 | }
48 |
49 | public start(): void {
50 | this.actor.start();
51 | }
52 |
53 | public send(message: TEvent): void {
54 | this.actor.receive(message);
55 | }
56 |
57 | public signal(signal: ActorSignal): void {
58 | this.actor.receiveSignal(signal);
59 | }
60 |
61 | public subscribe(
62 | listener?: Listener | Observer | null,
63 | errorListener: Listener = unhandledErrorListener
64 | ) {
65 | const observer =
66 | typeof listener === 'function'
67 | ? ({
68 | next: listener,
69 | error: errorListener,
70 | } as Observer)
71 | : listener;
72 |
73 | return this.actor.subscribe(observer);
74 | }
75 |
76 | public getSnapshot(): TEmitted {
77 | return this.actor.getSnapshot();
78 | }
79 |
80 | public [symbolObservable]() {
81 | return this;
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/ActorSystem.ts:
--------------------------------------------------------------------------------
1 | import { Listener } from './Actor';
2 | import { XActorRef } from './ActorRef';
3 | import { symbolObservable } from './observable';
4 | import { Behavior, Subscribable, Observer, EventObject } from './types';
5 |
6 | export class ActorSystem
7 | implements Subscribable {
8 | public settings: any;
9 | private guardian: XActorRef;
10 | public logger = (actorRef: XActorRef) => (...args: any[]) => {
11 | const label =
12 | actorRef === this.guardian
13 | ? `[${this.name}]`
14 | : `[${this.name}/${actorRef.name}]`;
15 | console.log(label, ...args);
16 | };
17 |
18 | // TODO: structured logging
19 | public logs: Array<
20 | | {
21 | from: XActorRef;
22 | to: XActorRef;
23 | message: any;
24 | }
25 | | {
26 | ref: XActorRef;
27 | log: string;
28 | }
29 | > = [];
30 |
31 | constructor(behavior: Behavior, public name: string) {
32 | this.guardian = new XActorRef(behavior, name, this);
33 | this.guardian.start();
34 | }
35 |
36 | public send(message: TEvent) {
37 | this.guardian.send(message);
38 | }
39 |
40 | public subscribe(listener?: Listener | Observer | null) {
41 | return this.guardian.subscribe(listener);
42 | }
43 |
44 | public [symbolObservable]() {
45 | return this;
46 | }
47 |
48 | public getSnapshot(): TEmitted {
49 | return this.guardian.getSnapshot();
50 | }
51 | }
52 |
53 | export function createSystem(
54 | behavior: Behavior,
55 | name: string
56 | ) {
57 | return new ActorSystem(behavior, name);
58 | }
59 |
--------------------------------------------------------------------------------
/src/Behavior.ts:
--------------------------------------------------------------------------------
1 | import { XActorRef } from './ActorRef';
2 | import {
3 | ActorContext,
4 | BehaviorTag,
5 | ActorSignal,
6 | ActorSignalType,
7 | Behavior,
8 | TaggedState,
9 | BehaviorReducer,
10 | Subscribable,
11 | Observer,
12 | Subscription,
13 | EventObject,
14 | } from './types';
15 |
16 | export const isSignal = (msg: any): msg is ActorSignal => {
17 | return (
18 | msg !== null &&
19 | typeof msg === 'object' &&
20 | Object.values(ActorSignalType).includes(msg.type)
21 | );
22 | };
23 |
24 | export function isTaggedState(
25 | state: TState | TaggedState
26 | ): state is TaggedState {
27 | return typeof state === 'object' && state !== null && '$$tag' in state;
28 | }
29 |
30 | function createContextProxy(
31 | ctx: ActorContext
32 | ): [ActorContext, any[]] {
33 | const effects: any[] = [];
34 |
35 | return [
36 | {
37 | ...ctx,
38 | spawn: (behavior, name) => {
39 | const actor = ctx.spawn(behavior, name);
40 |
41 | effects.push({
42 | type: 'start',
43 | actor,
44 | });
45 |
46 | return actor;
47 | },
48 | spawnFrom: (getEntity, name) => {
49 | const actor = ctx.spawnFrom(getEntity, name);
50 |
51 | effects.push({
52 | type: 'start',
53 | actor,
54 | });
55 |
56 | return actor;
57 | },
58 | },
59 | effects,
60 | ];
61 | }
62 |
63 | export function createBehavior(
64 | reducer: BehaviorReducer,
65 | initial: TState
66 | ): Behavior {
67 | return [
68 | (taggedState, msg, ctx) => {
69 | const { state, $$tag: tag } = taggedState;
70 |
71 | const [ctxProxy, effects] = createContextProxy(ctx);
72 | const nextState = reducer(state, msg, ctxProxy);
73 |
74 | const nextTaggedState = isTaggedState(nextState)
75 | ? nextState
76 | : {
77 | state: nextState,
78 | $$tag: tag === BehaviorTag.Setup ? BehaviorTag.Default : tag,
79 | effects,
80 | };
81 |
82 | return nextTaggedState;
83 | },
84 | { state: initial, $$tag: BehaviorTag.Setup, effects: [] },
85 | ];
86 | }
87 |
88 | export function createStatelessBehavior(
89 | reducer: BehaviorReducer
90 | ): Behavior {
91 | return createBehavior((_, msg, ctx) => {
92 | reducer(undefined, msg, ctx);
93 | return undefined;
94 | }, undefined);
95 | }
96 |
97 | export function createSetupBehavior(
98 | setup: (
99 | initialState: TState,
100 | ctx: ActorContext
101 | ) => TState | TaggedState,
102 | reducer: BehaviorReducer,
103 | initial: TState
104 | ): Behavior {
105 | return [
106 | (taggedState, msg, ctx) => {
107 | const { state, $$tag: tag } = taggedState;
108 | const isSetup = tag === BehaviorTag.Setup;
109 |
110 | const [ctxProxy, effects] = createContextProxy(ctx);
111 | const nextState = isSetup
112 | ? setup(state, ctxProxy)
113 | : reducer(state, msg, ctxProxy);
114 |
115 | const nextTaggedState = isTaggedState(nextState)
116 | ? { ...nextState, effects }
117 | : {
118 | state: nextState,
119 | $$tag: isSetup ? BehaviorTag.Default : tag,
120 | effects,
121 | };
122 |
123 | return nextTaggedState;
124 | },
125 | { state: initial, $$tag: BehaviorTag.Setup, effects: [] },
126 | ];
127 | }
128 |
129 | export function withTag(
130 | state: TState,
131 | tag: BehaviorTag = BehaviorTag.Default
132 | ): TaggedState {
133 | return {
134 | state,
135 | $$tag: tag,
136 | effects: [],
137 | };
138 | }
139 |
140 | export function createTimeout(
141 | parentRef: XActorRef,
142 | fn: (parentRef: XActorRef) => void,
143 | timeout: number
144 | ): Behavior {
145 | return [
146 | s => {
147 | setTimeout(() => {
148 | fn(parentRef);
149 | }, timeout);
150 | return s;
151 | },
152 | withTag(undefined),
153 | ];
154 | }
155 |
156 | export function stopped(
157 | state: TState
158 | ): TaggedState & { $$tag: BehaviorTag.Stopped } {
159 | return {
160 | state,
161 | $$tag: BehaviorTag.Stopped,
162 | effects: [],
163 | };
164 | }
165 |
166 | export function fromPromise(
167 | getPromise: () => Promise,
168 | resolve: (value: T) => void,
169 | reject?: (error: any) => void
170 | ): Behavior {
171 | return [
172 | taggedState => {
173 | if (taggedState.$$tag === BehaviorTag.Setup) {
174 | getPromise().then(resolve, reject);
175 |
176 | return withTag(taggedState.state, BehaviorTag.Default);
177 | }
178 |
179 | return taggedState;
180 | },
181 | withTag(undefined, BehaviorTag.Setup),
182 | ];
183 | }
184 |
185 | export function fromObservable(
186 | getObservable: () => Subscribable,
187 | observer: Observer
188 | ): Behavior {
189 | let sub: Subscription;
190 |
191 | return [
192 | (taggedState, msg) => {
193 | if (taggedState.$$tag === BehaviorTag.Setup) {
194 | sub = getObservable().subscribe(observer);
195 |
196 | return withTag(taggedState.state, BehaviorTag.Default);
197 | }
198 |
199 | if (isSignal(msg) && msg.type === ActorSignalType.PostStop) {
200 | sub?.unsubscribe();
201 |
202 | return stopped(undefined);
203 | }
204 |
205 | return taggedState;
206 | },
207 | withTag(undefined, BehaviorTag.Setup),
208 | ];
209 | }
210 |
211 | export function fromEntity(
212 | getEntity: () => Promise | Subscribable,
213 | observer: Observer
214 | ): Behavior {
215 | return [
216 | taggedState => {
217 | if (taggedState.$$tag === BehaviorTag.Setup) {
218 | const entity = getEntity();
219 |
220 | if ('subscribe' in entity) {
221 | entity.subscribe(observer);
222 | } else {
223 | entity.then(observer.next, observer.error);
224 | }
225 |
226 | return withTag(taggedState.state, BehaviorTag.Default);
227 | }
228 |
229 | return taggedState;
230 | },
231 | withTag(undefined, BehaviorTag.Setup),
232 | ];
233 | }
234 |
--------------------------------------------------------------------------------
/src/Topic.ts:
--------------------------------------------------------------------------------
1 | // import { ActorContext, BehaviorTag } from './Behavior';
2 | // import { ActorRef } from './ActorRef';
3 |
4 | // type TopicCommand =
5 | // | {
6 | // type: 'publish';
7 | // message: T;
8 | // }
9 | // | {
10 | // type: 'messagePublished';
11 | // message: T;
12 | // }
13 | // | {
14 | // type: 'subscribe';
15 | // subscriber: ActorRef;
16 | // };
17 |
18 | // export class Topic implements Behavior> {
19 | // public _tag = BehaviorTag.Topic;
20 | // private subscribers = new Set>();
21 |
22 | // constructor(public topicName: string) {}
23 |
24 | // public receive(ctx: ActorContext>, msg: TopicCommand) {
25 | // switch (msg.type) {
26 | // case 'publish':
27 | // ctx.log(`Publishing message of type [${msg.type}]`);
28 | // this.subscribers.forEach(subscriber => {
29 | // subscriber.send(msg.message);
30 | // });
31 | // return this;
32 | // case 'subscribe':
33 | // ctx.log(`Local subscriber [${msg.subscriber.name}] added`);
34 | // this.subscribers.add(msg.subscriber);
35 | // return this;
36 | // default:
37 | // return this;
38 | // }
39 | // }
40 | // }
41 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { ActorSystem, createSystem } from './ActorSystem';
2 | export { XActorRef as ActorRef } from './ActorRef';
3 | export {
4 | createBehavior,
5 | createSetupBehavior,
6 | createStatelessBehavior,
7 | isSignal,
8 | stopped,
9 | createTimeout,
10 | } from './Behavior';
11 |
--------------------------------------------------------------------------------
/src/observable.ts:
--------------------------------------------------------------------------------
1 | export const symbolObservable = (() =>
2 | (typeof Symbol === 'function' && (Symbol as any).observable) ||
3 | '@@observable')();
4 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { XActorRef } from './ActorRef';
2 | import { ActorSystem } from './ActorSystem';
3 |
4 | export interface Subscription {
5 | unsubscribe(): void;
6 | }
7 |
8 | // export interface Observer {
9 | // // Sends the next value in the sequence
10 | // next?: (value: T) => void;
11 |
12 | // // Sends the sequence error
13 | // error?: (errorValue: any) => void;
14 |
15 | // // Sends the completion notification
16 | // complete: any; // TODO: what do you want, RxJS???
17 | // }
18 |
19 | /** OBSERVER INTERFACES - from RxJS */
20 | export interface NextObserver {
21 | closed?: boolean;
22 | next: (value: T) => void;
23 | error?: (err: any) => void;
24 | complete?: () => void;
25 | }
26 | export interface ErrorObserver {
27 | closed?: boolean;
28 | next?: (value: T) => void;
29 | error: (err: any) => void;
30 | complete?: () => void;
31 | }
32 | export interface CompletionObserver {
33 | closed?: boolean;
34 | next?: (value: T) => void;
35 | error?: (err: any) => void;
36 | complete: () => void;
37 | }
38 |
39 | export type Observer =
40 | | NextObserver
41 | | ErrorObserver
42 | | CompletionObserver;
43 |
44 | export interface Subscribable {
45 | subscribe(observer: Observer): Subscription;
46 | subscribe(
47 | next: (value: T) => void,
48 | error?: (error: any) => void,
49 | complete?: () => void
50 | ): Subscription;
51 | }
52 |
53 | export interface SubscribableByObserver {
54 | subscribe(observer: Observer): Subscription;
55 | }
56 |
57 | export type Logger = any;
58 |
59 | export interface ActorContext {
60 | self: XActorRef;
61 | system: ActorSystem;
62 | log: Logger;
63 | children: Set>;
64 | watch: (actorRef: XActorRef) => void;
65 | send: (actorRef: XActorRef, message: U) => void;
66 | subscribeTo: (topic: 'watchers', subscriber: XActorRef) => void;
67 |
68 | // spawnAnonymous(behavior: Behavior): ActorRef;
69 | spawn(
70 | behavior: Behavior,
71 | name: string
72 | ): XActorRef;
73 | spawnFrom(
74 | getEntity: () => Promise | Subscribable,
75 | name: string
76 | ): XActorRef;
77 | stop(child: XActorRef): void;
78 | }
79 |
80 | export enum ActorSignalType {
81 | Start,
82 | PostStop,
83 | Watch,
84 | Terminated,
85 | Subscribe,
86 | Emit,
87 | }
88 |
89 | export type ActorSignal =
90 | | { type: ActorSignalType.Start }
91 | | { type: ActorSignalType.PostStop }
92 | | { type: ActorSignalType.Watch; ref: XActorRef }
93 | | { type: ActorSignalType.Terminated; ref: XActorRef }
94 | | { type: ActorSignalType.Subscribe; ref: XActorRef }
95 | | { type: ActorSignalType.Emit; value: any };
96 |
97 | export enum BehaviorTag {
98 | Setup,
99 | Default,
100 | Stopped,
101 | }
102 |
103 | export interface TaggedState {
104 | state: TState;
105 | $$tag: BehaviorTag;
106 | effects: any[];
107 | }
108 |
109 | export type Behavior = [
110 | (
111 | state: TaggedState,
112 | message: TEvent | ActorSignal,
113 | ctx: ActorContext
114 | ) => TaggedState,
115 | TaggedState
116 | ];
117 |
118 | export type BehaviorReducer = (
119 | state: TState,
120 | event: TEvent | ActorSignal,
121 | actorCtx: ActorContext
122 | ) => TState | TaggedState;
123 |
124 | export interface EventObject {
125 | type: string;
126 | }
127 |
--------------------------------------------------------------------------------
/test/actor.test.ts:
--------------------------------------------------------------------------------
1 | import { createBehavior, createSystem } from '../src';
2 |
3 | describe('getSnapshot() method', () => {
4 | it('should return a snapshot of the most recently emitted state', () => {
5 | const behavior = createBehavior<{ type: 'update'; value: number }>(
6 | (state, msg) => {
7 | if (msg.type === 'update') {
8 | return msg.value;
9 | }
10 |
11 | return state;
12 | },
13 | 42
14 | );
15 | const system = createSystem(behavior, 'test');
16 |
17 | expect(system.getSnapshot()).toEqual(42);
18 | });
19 |
20 | it('should keep snapshot up to date after state changes', () => {
21 | const behavior = createBehavior<{ type: 'update'; value: number }>(
22 | (state, msg) => {
23 | if (msg.type === 'update') {
24 | return msg.value;
25 | }
26 |
27 | return state;
28 | },
29 | 42
30 | );
31 | const system = createSystem(behavior, 'test');
32 |
33 | expect(system.getSnapshot()).toEqual(42);
34 |
35 | system.send({ type: 'update', value: 55 });
36 |
37 | setTimeout(() => {
38 | expect(system.getSnapshot()).toEqual(55);
39 | });
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/test/actorSystem.test.ts:
--------------------------------------------------------------------------------
1 | // import * as behaviors from '../src/BehaviorImpl';
2 | // import { ActorSignalType, Logger } from '../src/Behavior';
3 | import {
4 | createBehavior,
5 | isSignal,
6 | createSetupBehavior,
7 | stopped,
8 | createTimeout,
9 | createSystem,
10 | ActorRef,
11 | } from '../src';
12 | import { ActorSignalType, Logger, BehaviorReducer } from '../src/types';
13 |
14 | describe('ActorSystem', () => {
15 | it('simple test', done => {
16 | const rootBehavior = createBehavior((_, msg) => {
17 | if (isSignal(msg)) return false;
18 | expect(msg).toEqual({ type: 'hey' });
19 | return true;
20 | }, false);
21 |
22 | const system = createSystem(rootBehavior, 'hello');
23 |
24 | system.subscribe(state => {
25 | if (state) {
26 | done();
27 | }
28 | });
29 |
30 | system.send({ type: 'hey' });
31 | });
32 |
33 | it('First example', done => {
34 | interface Greet {
35 | type: 'greet';
36 | whom: string;
37 | replyTo: ActorRef;
38 | }
39 | interface Greeted {
40 | type: 'greeted';
41 | whom: string;
42 | from: ActorRef;
43 | }
44 |
45 | interface SayHello {
46 | type: 'sayHello';
47 | name: string;
48 | }
49 |
50 | const HelloWorld = createBehavior((_, message, ctx) => {
51 | if (isSignal(message)) return _;
52 |
53 | ctx.log(`Hello ${message.whom}!`);
54 |
55 | message.replyTo.send({
56 | type: 'greeted',
57 | whom: message.whom,
58 | from: ctx.self,
59 | });
60 |
61 | return _;
62 | }, undefined);
63 |
64 | const HelloWorldBot = (max: number) => {
65 | const bot = (greetingCounter: number, max: number) => {
66 | return createBehavior(
67 | (state, message, ctx) => {
68 | if (isSignal(message)) return state;
69 |
70 | const n = state.n + 1;
71 |
72 | ctx.log(`Greeting ${n} for ${message.whom}`);
73 |
74 | if (n === max) {
75 | return state; // do nothing
76 | } else {
77 | message.from.send({
78 | type: 'greet',
79 | whom: message.whom,
80 | replyTo: ctx.self,
81 | });
82 | return { n, max };
83 | }
84 | },
85 | { n: greetingCounter, max }
86 | );
87 | };
88 |
89 | return bot(0, max);
90 | };
91 |
92 | const HelloWorldMain = createSetupBehavior<
93 | SayHello,
94 | { greeter: ActorRef | undefined }
95 | >(
96 | (_, ctx) => {
97 | return { greeter: ctx.spawn(HelloWorld, 'greeter') };
98 | },
99 | ({ greeter }, message, ctx) => {
100 | if (message.type === 'sayHello') {
101 | const replyTo = ctx.spawn(HelloWorldBot(3), message.name);
102 |
103 | greeter?.send({
104 | type: 'greet',
105 | whom: message.name,
106 | replyTo,
107 | });
108 | }
109 |
110 | return { greeter };
111 | },
112 | {
113 | greeter: undefined,
114 | }
115 | );
116 |
117 | const system = createSystem(HelloWorldMain, 'hello');
118 |
119 | system.send({ type: 'sayHello', name: 'World' });
120 | system.send({ type: 'sayHello', name: 'Akka' });
121 |
122 | setTimeout(() => {
123 | done();
124 | }, 1000);
125 | });
126 |
127 | it('more complex example', done => {
128 | interface GetSession {
129 | type: 'GetSession';
130 | screenName: string;
131 | replyTo: ActorRef;
132 | }
133 |
134 | type RoomCommand = GetSession | PublishSessionMessage;
135 |
136 | interface SessionGranted {
137 | type: 'SessionGranted';
138 | handle: ActorRef;
139 | }
140 |
141 | interface SessionDenied {
142 | type: 'SessionDenied';
143 | reason: string;
144 | }
145 |
146 | interface MessagePosted {
147 | type: 'MessagePosted';
148 | screenName: string;
149 | message: string;
150 | }
151 |
152 | type SessionEvent = SessionGranted | SessionDenied | MessagePosted;
153 |
154 | interface PostMessage {
155 | type: 'PostMessage';
156 | message: string;
157 | }
158 |
159 | interface NotifyClient {
160 | type: 'NotifyClient';
161 | message: MessagePosted;
162 | }
163 |
164 | type SessionCommand = PostMessage | NotifyClient;
165 |
166 | interface PublishSessionMessage {
167 | type: 'PublishSessionMessage';
168 | screenName: string;
169 | message: string;
170 | }
171 |
172 | const ChatRoom = () => chatRoom([]);
173 |
174 | const session = (
175 | room: ActorRef,
176 | screenName: string,
177 | client: ActorRef
178 | ) => {
179 | return createBehavior((_, message, _ctx) => {
180 | switch (message.type) {
181 | case 'PostMessage':
182 | room.send({
183 | type: 'PublishSessionMessage',
184 | screenName,
185 | message: message.message,
186 | });
187 | return undefined;
188 | case 'NotifyClient':
189 | client.send(message.message);
190 | return undefined;
191 | default:
192 | return undefined;
193 | }
194 | }, undefined);
195 | };
196 |
197 | const chatRoom = (sessions: ActorRef[]) => {
198 | return createBehavior<
199 | RoomCommand,
200 | { sessions: ActorRef[] }
201 | >(
202 | (state, message, context) => {
203 | context.log(message);
204 | switch (message.type) {
205 | case 'GetSession':
206 | const ses = context.spawn(
207 | session(
208 | context.self as any,
209 | message.screenName,
210 | message.replyTo
211 | ),
212 | message.screenName
213 | );
214 | message.replyTo.send({
215 | type: 'SessionGranted',
216 | handle: ses as any,
217 | });
218 | return { sessions: [ses, ...sessions] };
219 | case 'PublishSessionMessage':
220 | const notification: NotifyClient = {
221 | type: 'NotifyClient',
222 | message: {
223 | type: 'MessagePosted',
224 | screenName: message.screenName,
225 | message: message.message,
226 | },
227 | };
228 | state.sessions.forEach(session => session.send(notification));
229 | return state;
230 | default:
231 | return state;
232 | }
233 | },
234 | { sessions }
235 | );
236 | };
237 |
238 | const Gabbler = () => {
239 | return createBehavior((_, message, context) => {
240 | context.log(message);
241 | switch (message.type) {
242 | case 'SessionGranted':
243 | message.handle.send({
244 | type: 'PostMessage',
245 | message: 'Hello world!',
246 | });
247 | return undefined;
248 | case 'MessagePosted':
249 | context.log(
250 | `message has been posted by '${message.screenName}': ${message.message}`
251 | );
252 | done();
253 | return undefined;
254 | // return BehaviorTag.Stopped;
255 | default:
256 | return undefined;
257 | }
258 | }, undefined);
259 | };
260 |
261 | const Main = () =>
262 | createSetupBehavior(
263 | (_, context) => {
264 | context.log('setting up');
265 | const chatRoom = context.spawn(ChatRoom(), 'chatRoom');
266 | const gabblerRef = context.spawn(Gabbler(), 'gabbler');
267 |
268 | chatRoom.send({
269 | type: 'GetSession',
270 | screenName: "ol' Gabbler",
271 | replyTo: gabblerRef,
272 | });
273 | return undefined;
274 | },
275 | s => s,
276 | undefined
277 | );
278 |
279 | createSystem(Main(), 'Chat');
280 | });
281 |
282 | it('aggregation example', done => {
283 | interface OrchestratorState {
284 | entities: Map>;
285 | aggregations: {
286 | [entityId: string]: number | undefined;
287 | };
288 | }
289 | type OrchestratorEvent =
290 | | {
291 | type: 'entity.add';
292 | entityId: string;
293 | value: number;
294 | }
295 | | {
296 | type: 'entity.receive';
297 | entity: ActorRef;
298 | count: number;
299 | }
300 | | {
301 | type: 'getAll';
302 | };
303 |
304 | type EntityEvent =
305 | | {
306 | type: 'add';
307 | value: number;
308 | }
309 | | {
310 | type: 'get';
311 | ref: ActorRef;
312 | };
313 |
314 | interface EntityState {
315 | count: number;
316 | }
317 |
318 | const entityReducer: BehaviorReducer = (
319 | state,
320 | event,
321 | ctx
322 | ) => {
323 | if (event.type === 'add') {
324 | ctx.log(`adding ${event.value} ${state.count}`);
325 | state.count += event.value;
326 | }
327 |
328 | if (event.type === 'get') {
329 | event.ref.send({
330 | type: 'entity.receive',
331 | entity: ctx.self,
332 | count: state.count,
333 | });
334 | }
335 |
336 | return state;
337 | };
338 |
339 | const orchestratorReducer: BehaviorReducer<
340 | OrchestratorState,
341 | OrchestratorEvent
342 | > = (state, event, ctx) => {
343 | if (event.type === 'entity.add') {
344 | let entity = state.entities.get(event.entityId);
345 | if (!entity) {
346 | entity = ctx.spawn(
347 | createBehavior(entityReducer, { count: 0 }),
348 | event.entityId
349 | );
350 | state.entities.set(event.entityId, entity);
351 | }
352 |
353 | entity.send({ type: 'add', value: event.value });
354 | }
355 |
356 | if (event.type === 'getAll') {
357 | Array.from(state.entities.entries()).forEach(([entityId, entity]) => {
358 | state.aggregations[entityId] = undefined;
359 |
360 | entity.send({ type: 'get', ref: ctx.self });
361 | });
362 | }
363 |
364 | if (event.type === 'entity.receive') {
365 | state.aggregations[event.entity.name] = event.count;
366 |
367 | if (
368 | Object.values(state.aggregations).every(value => value !== undefined)
369 | ) {
370 | ctx.log(state.aggregations);
371 | done();
372 | }
373 | }
374 |
375 | return state;
376 | };
377 |
378 | const system = createSystem(
379 | createBehavior(orchestratorReducer, {
380 | entities: new Map(),
381 | aggregations: {},
382 | }),
383 | 'orchestrator'
384 | );
385 |
386 | system.send({
387 | type: 'entity.add',
388 | entityId: 'foo',
389 | value: 3,
390 | });
391 |
392 | system.send({
393 | type: 'entity.add',
394 | entityId: 'foo',
395 | value: 3,
396 | });
397 |
398 | system.send({
399 | type: 'entity.add',
400 | entityId: 'foo',
401 | value: 2,
402 | });
403 |
404 | system.send({
405 | type: 'entity.add',
406 | entityId: 'bar',
407 | value: 3,
408 | });
409 |
410 | system.send({
411 | type: 'entity.add',
412 | entityId: 'bar',
413 | value: 3,
414 | });
415 |
416 | system.send({
417 | type: 'entity.add',
418 | entityId: 'bar',
419 | value: 2,
420 | });
421 |
422 | system.send({
423 | type: 'entity.add',
424 | entityId: 'foo',
425 | value: 1,
426 | });
427 |
428 | system.send({
429 | type: 'getAll',
430 | });
431 | });
432 |
433 | it('guardian actor should receive messages sent to system', done => {
434 | const HelloWorldMain = createBehavior<{ type: 'hello' }>((_, event) => {
435 | if (event.type === 'hello') {
436 | done();
437 | }
438 | }, undefined);
439 |
440 | const system = createSystem(HelloWorldMain, 'hello');
441 |
442 | system.send({ type: 'hello' });
443 | });
444 |
445 | it('stopping actors', done => {
446 | // https://doc.akka.io/docs/akka/2.6.5/typed/actor-lifecycle.html#stopping-actors
447 | const stoppedActors: any[] = [];
448 |
449 | interface SpawnJob {
450 | type: 'SpawnJob';
451 | name: string;
452 | }
453 |
454 | interface GracefulShutdown {
455 | type: 'GracefulShutdown';
456 | }
457 |
458 | type Command = SpawnJob | GracefulShutdown;
459 |
460 | const Job = (name: string) =>
461 | createBehavior((_, signal, ctx) => {
462 | ctx.log(signal);
463 | if (signal.type === ActorSignalType.PostStop) {
464 | ctx.log(`Worker ${name} stopped`);
465 | stoppedActors.push(name);
466 | }
467 | }, undefined);
468 |
469 | const MasterControlProgram = () =>
470 | createBehavior((state, message, context) => {
471 | const cleanup = (log: Logger): void => {
472 | log(`Cleaning up!`);
473 | };
474 |
475 | if (isSignal(message)) {
476 | if (message.type === ActorSignalType.PostStop) {
477 | context.log(`Master Control Program stopped`);
478 | cleanup(context.log);
479 |
480 | expect(stoppedActors).toEqual(['a', 'b']);
481 | done();
482 | }
483 | return;
484 | }
485 |
486 | switch (message.type) {
487 | case 'SpawnJob':
488 | const { name: jobName } = message;
489 | context.log(`Spawning job ${jobName}!`);
490 | context.spawn(Job(jobName), jobName);
491 | return;
492 | case 'GracefulShutdown':
493 | context.log(`Initiating graceful shutdown...`);
494 | return stopped(state);
495 | }
496 | }, undefined);
497 |
498 | const system = createSystem(MasterControlProgram(), 'B7700');
499 |
500 | system.send({ type: 'SpawnJob', name: 'a' });
501 | system.send({ type: 'SpawnJob', name: 'b' });
502 |
503 | setTimeout(() => {
504 | system.send({ type: 'GracefulShutdown' });
505 | }, 100);
506 | });
507 |
508 | it('watching actors', done => {
509 | interface SpawnJob {
510 | type: 'SpawnJob';
511 | jobName: string;
512 | }
513 |
514 | const Job = (name: string) =>
515 | createSetupBehavior<{ type: 'finished' }, undefined>(
516 | (_, ctx) => {
517 | ctx.spawn(
518 | createTimeout(
519 | ctx.self,
520 | ref => {
521 | ref.send({ type: 'finished' });
522 | },
523 | 100
524 | ),
525 | 'timeout'
526 | );
527 |
528 | ctx.log(`Hi I am job ${name}`);
529 | return undefined;
530 | },
531 | (state, event) => {
532 | if (event.type === 'finished') {
533 | return stopped(state);
534 | }
535 |
536 | return state;
537 | },
538 | undefined
539 | );
540 |
541 | const MasterControlProgram = () =>
542 | createBehavior((state, message, context) => {
543 | if (isSignal(message)) {
544 | switch (message.type) {
545 | case ActorSignalType.Terminated:
546 | context.log(`Job stopped: ${message.ref.name}`);
547 | expect(message.ref.name).toEqual('job1');
548 | done();
549 | return state;
550 | default:
551 | return state;
552 | }
553 | }
554 |
555 | switch (message.type) {
556 | case 'SpawnJob':
557 | context.log(`Spawning job ${message.jobName}!`);
558 | const job = context.spawn(Job(message.jobName), message.jobName);
559 | context.watch(job);
560 | return state;
561 | default:
562 | return state;
563 | }
564 | }, undefined);
565 |
566 | const sys = createSystem(MasterControlProgram(), 'master');
567 |
568 | sys.send({
569 | type: 'SpawnJob',
570 | jobName: 'job1',
571 | });
572 | }, 1000);
573 |
574 | describe('interaction patterns', () => {
575 | it('fire and forget', done => {
576 | interface PrintMe {
577 | type: 'PrintMe';
578 | message: string;
579 | }
580 |
581 | // const Printer = (): Behavior => {
582 | // return receive((ctx, msg) => {
583 | // switch (msg.type) {
584 | // case 'PrintMe':
585 | // ctx.log(msg.message);
586 | // if (msg.message === 'not message 2') {
587 | // done();
588 | // }
589 | // return BehaviorTag.Same;
590 | // }
591 | // });
592 | // };
593 |
594 | const Printer = () =>
595 | createBehavior((state, msg, ctx) => {
596 | switch (msg.type) {
597 | case 'PrintMe':
598 | ctx.log(msg.message);
599 | if (msg.message === 'not message 2') {
600 | done();
601 | }
602 | return state;
603 | }
604 | }, undefined);
605 |
606 | const sys = createSystem(Printer(), 'fire-and-forget-sample');
607 |
608 | sys.send({ type: 'PrintMe', message: 'message 1' });
609 | sys.send({ type: 'PrintMe', message: 'not message 2' });
610 | });
611 |
612 | it('request-response', done => {
613 | interface ResponseMsg {
614 | type: 'Response';
615 | result: string;
616 | }
617 | interface RequestMsg {
618 | type: 'Request';
619 | query: string;
620 | replyTo: ActorRef;
621 | }
622 |
623 | const CookieFabric = () =>
624 | createBehavior((state, msg, ctx) => {
625 | switch (msg.type) {
626 | case 'Request':
627 | ctx.send(msg.replyTo, {
628 | type: 'Response',
629 | result: `Here are the cookies for [${msg.query}]!`,
630 | });
631 | return state;
632 | default:
633 | return state;
634 | }
635 | }, undefined);
636 |
637 | const Requestor = () =>
638 | createBehavior((state, msg, ctx) => {
639 | switch (msg.type) {
640 | case 'start':
641 | const cookieFabric = ctx.spawn(CookieFabric(), 'cookie-fabric');
642 |
643 | ctx.send(cookieFabric, {
644 | type: 'Request',
645 | query: 'my query',
646 | replyTo: ctx.self,
647 | });
648 |
649 | return state;
650 | case 'Response':
651 | ctx.log(`Got a response: ${msg.result}`);
652 | console.log(sys.logs);
653 |
654 | const participants: Set> = new Set();
655 |
656 | sys.logs.map(log => {
657 | if ('log' in log) {
658 | participants.add(log.ref);
659 | } else {
660 | participants.add(log.from);
661 | participants.add(log.to);
662 | }
663 | });
664 |
665 | const parr = Array.from(participants);
666 |
667 | const seqDiagram =
668 | `sequenceDiagram\n` +
669 | parr
670 | .map((value, index) => {
671 | return ` participant ${index} as ${value.name}`;
672 | })
673 | .join('\n') +
674 | '\n' +
675 | sys.logs
676 | .map(log => {
677 | if ('log' in log) {
678 | return ` Note right of ${parr.indexOf(log.ref)}: ${
679 | log.log
680 | }`;
681 | }
682 |
683 | const from = parr.indexOf(log.from);
684 | const to = parr.indexOf(log.to);
685 |
686 | return ` ${from}->>${to}: '${JSON.stringify(
687 | log.message,
688 | (_key, value) => {
689 | if (value instanceof ActorRef) {
690 | return value.name;
691 | }
692 | return value;
693 | }
694 | )}'`;
695 | })
696 | .join('\n');
697 |
698 | console.log(seqDiagram);
699 | done();
700 | return state;
701 | default:
702 | return state;
703 | }
704 | }, undefined);
705 |
706 | const sys = createSystem(Requestor(), 'test');
707 |
708 | sys.send({ type: 'start' });
709 | });
710 |
711 | // it('request-response with ask between two actors', (done) => {
712 | // // object Hal {
713 | // // sealed trait Command
714 | // // case class OpenThePodBayDoorsPlease(replyTo: ActorRef[Response]) extends Command
715 | // // case class Response(message: String)
716 |
717 | // // def apply(): Receive[Hal.Command] =
718 | // // receiveMessage[Command] {
719 | // // case OpenThePodBayDoorsPlease(replyTo) =>
720 | // // replyTo ! Response("I'm sorry, Dave. I'm afraid I can't do that.")
721 | // // same
722 | // // }
723 | // // }
724 |
725 | // interface HalResponse {
726 | // type: 'HalResponse';
727 | // message: string;
728 | // }
729 |
730 | // interface OpenThePodBayDoorsPlease {
731 | // type: 'OpenThePodBayDoorsPlease';
732 | // replyTo: ActorRef;
733 | // }
734 |
735 | // const Hal = () =>
736 | // receive((ctx, msg) => {
737 | // switch (msg.type) {
738 | // case 'OpenThePodBayDoorsPlease':
739 | // msg.replyTo.send({
740 | // type: 'HalResponse',
741 | // message: "I'm sorry, Dave. I'm afraid I can't do that.",
742 | // });
743 | // return BehaviorTag.Same;
744 | // }
745 | // });
746 | // });
747 | });
748 | });
749 |
--------------------------------------------------------------------------------
/test/behaviors.test.ts:
--------------------------------------------------------------------------------
1 | import { createBehavior, createSystem, isSignal } from '../src';
2 | import { from, interval } from 'rxjs';
3 | import { map } from 'rxjs/operators';
4 |
5 | describe('behaviors', () => {
6 | it('should result in same behavior', done => {
7 | const helloWorldBehavior = createBehavior<{ type: 'greet'; whom: string }>(
8 | (_, message) => {
9 | if (isSignal(message)) {
10 | return undefined;
11 | }
12 |
13 | console.log('Sup, ' + message.whom);
14 |
15 | return undefined;
16 | },
17 | undefined
18 | );
19 |
20 | const system = createSystem(helloWorldBehavior, 'Hello');
21 |
22 | system.send({ type: 'greet', whom: 'David' });
23 |
24 | setTimeout(() => {
25 | done();
26 | }, 1000);
27 | });
28 | });
29 |
30 | describe('promise behavior', () => {
31 | it('can receive a response from a resolved promise', done => {
32 | const behavior = createBehavior<{ type: 'response'; value: number }>(
33 | (state, msg, ctx) => {
34 | if (msg.type === 'response') {
35 | expect(msg.value).toEqual(42);
36 | done();
37 | return state;
38 | }
39 |
40 | if (state === 'idle') {
41 | ctx.spawnFrom(() => {
42 | return new Promise<{ type: 'response'; value: number }>(res => {
43 | setTimeout(() => {
44 | res({ type: 'response', value: 42 });
45 | }, 100);
46 | });
47 | }, 'promise');
48 |
49 | return 'pending';
50 | }
51 |
52 | return state;
53 | },
54 | 'idle'
55 | );
56 |
57 | // @ts-ignore
58 | const system = createSystem(behavior, 'sys');
59 | });
60 | });
61 |
62 | describe('observable behavior', () => {
63 | it('can receive multiple values from an observable', done => {
64 | const behavior = createBehavior<{ type: 'response'; value: number }>(
65 | (state, msg, ctx) => {
66 | if (msg.type === 'response' && msg.value === 3) {
67 | done();
68 | return state;
69 | }
70 |
71 | if (state === 'idle') {
72 | ctx.spawnFrom(() => {
73 | return interval(10).pipe(
74 | map(n => ({
75 | type: 'response',
76 | value: n,
77 | }))
78 | );
79 | }, 'observable');
80 |
81 | return 'pending';
82 | }
83 |
84 | return state;
85 | },
86 | 'idle'
87 | );
88 |
89 | // @ts-ignore
90 | const system = createSystem(behavior, 'sys');
91 | });
92 |
93 | it('can be consumed as an observable', done => {
94 | const behavior = createBehavior<{ type: 'event'; value: number }, number>(
95 | (state, message) => {
96 | if (message.type === 'event') {
97 | return message.value;
98 | }
99 |
100 | return state;
101 | },
102 | 0
103 | );
104 |
105 | const system = createSystem(behavior, 'sys');
106 |
107 | const num$ = from(system);
108 |
109 | num$.subscribe(value => {
110 | if (value === 42) {
111 | done();
112 | }
113 | });
114 |
115 | system.send({ type: 'event', value: 42 });
116 | });
117 | });
118 |
--------------------------------------------------------------------------------
/test/diningHakkers.test.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | // Adapted from https://github.com/akka/akka-samples/blob/2.6/akka-sample-fsm-scala/src/main/scala/sample/DiningHakkers.scala
3 |
4 | import { stat } from 'fs';
5 | import {
6 | ActorRef,
7 | createBehavior,
8 | createSetupBehavior,
9 | createSystem,
10 | createTimeout,
11 | } from '../src';
12 | import { ActorRefOf } from '../src/ActorRef';
13 |
14 | describe.skip('Dining hakkers', () => {
15 | it('should', done => {
16 | const Chopstick = () =>
17 | createBehavior(
18 | (state, event, ctx) => {
19 | switch (state.status) {
20 | case 'available':
21 | switch (event.type) {
22 | case 'take':
23 | ctx.send(event.hakker, {
24 | type: 'taken',
25 | chopstick: ctx.self,
26 | });
27 | return { ...state, status: 'takenBy', owner: event.hakker };
28 | default:
29 | return state;
30 | }
31 | case 'takenBy':
32 | switch (event.type) {
33 | case 'take':
34 | ctx.send(event.hakker, { type: 'busy', chopstick: ctx.self });
35 | return state;
36 | case 'put':
37 | return { ...state, status: 'available' };
38 | default:
39 | return state;
40 | }
41 | default:
42 | return state;
43 | }
44 | },
45 | { status: 'available', owner: null }
46 | );
47 |
48 | type ChopstickActorRef = ActorRefOf>;
49 |
50 | const Hakker = (
51 | name: string,
52 | left: ChopstickActorRef,
53 | right: ChopstickActorRef
54 | ) =>
55 | createBehavior(
56 | (state, event, ctx) => {
57 | ctx.log(state.status, event.type);
58 | switch (state.status) {
59 | case 'waiting':
60 | switch (event.type) {
61 | case 'think':
62 | ctx.log(`${name} starts to think`);
63 | ctx.spawn(
64 | createTimeout(
65 | ctx.self,
66 | ref => {
67 | ref.send({ type: 'thinkingStarted' });
68 | ref.send({ type: 'eat' });
69 | },
70 | 2000
71 | ),
72 | 'timeout'
73 | );
74 | return { ...state, status: 'startThinking' };
75 | default:
76 | return state;
77 | }
78 | case 'startThinking':
79 | switch (event.type) {
80 | case 'thinkingStarted':
81 | ctx.log('thinking started');
82 | return { ...state, status: 'thinking' };
83 | default:
84 | return state;
85 | }
86 | case 'thinking':
87 | switch (event.type) {
88 | case 'eat':
89 | ctx.send(left, { type: 'take', hakker: ctx.self });
90 | ctx.send(right, { type: 'take', hakker: ctx.self });
91 | return { ...state, status: 'hungry' };
92 | default:
93 | return state;
94 | }
95 |
96 | case 'hungry':
97 | function waitForOtherChopstick(
98 | chopstickToWaitFor: ChopstickActorRef,
99 | takenChopstick: ChopstickActorRef
100 | ) {
101 | switch (event.type) {
102 | case 'taken':
103 | ctx.log(
104 | `${name} has picked up ${left.name} and ${right.name} and starts to eat`
105 | );
106 | ctx.spawn(
107 | createTimeout(
108 | ctx.self,
109 | ref => {
110 | ref.send({ type: 'think' });
111 | ref.send({ type: 'eatingStarted' });
112 | },
113 | 2000
114 | ),
115 | 'timer'
116 | );
117 | return {
118 | ...state,
119 | status: 'startEating',
120 | };
121 | case 'busy':
122 | ctx.send(takenChopstick, { type: 'put' });
123 | ctx.spawn(
124 | createTimeout(
125 | ctx.self,
126 | ref => {
127 | ref.send({ type: 'eat' });
128 | ref.send({ type: 'thinkingStarted' });
129 | },
130 | 10
131 | ),
132 | 'timer'
133 | );
134 | return { ...state, status: 'startThinking' };
135 | }
136 | }
137 | switch (event.type) {
138 | case 'taken':
139 | if (event.chopstick === left) {
140 | return waitForOtherChopstick(right, left);
141 | } else {
142 | return waitForOtherChopstick(left, right);
143 | }
144 | case 'busy':
145 | return {
146 | ...state,
147 | status: 'firstChopstickDenied',
148 | };
149 | }
150 |
151 | case 'startEating':
152 | if (event.type === 'eatingStarted') {
153 | return { ...state, status: 'eating' };
154 | }
155 |
156 | case 'eating':
157 | switch (event.type) {
158 | case 'think':
159 | ctx.log(
160 | `${name} puts down their chopsticks and starts to think`
161 | );
162 | ctx.send(left, { type: 'put' });
163 | ctx.send(right, { type: 'put' });
164 |
165 | ctx.spawn(
166 | createTimeout(
167 | ctx.self,
168 | ref => {
169 | ref.send({ type: 'thinkingStarted' });
170 | },
171 | 2000
172 | ),
173 | 'timeout'
174 | );
175 |
176 | return {
177 | ...state,
178 | status: 'startThinking',
179 | };
180 | }
181 |
182 | case 'firstChopstickDenied':
183 | switch (event.type) {
184 | case 'taken':
185 | ctx.send(event.chopstick, { type: 'put' });
186 | ctx.spawn(
187 | createTimeout(
188 | ctx.self,
189 | ref => {
190 | ref.send({ type: 'thinkingStarted' });
191 | },
192 | 10
193 | ),
194 | 'timeout'
195 | );
196 | return {
197 | ...state,
198 | status: 'startThinking',
199 | };
200 | case 'busy':
201 | ctx.spawn(
202 | createTimeout(
203 | ctx.self,
204 | ref => {
205 | ref.send({ type: 'thinkingStarted' });
206 | },
207 | 10
208 | ),
209 | 'timeout'
210 | );
211 | return {
212 | ...state,
213 | status: 'startThinking',
214 | };
215 | default:
216 | return state;
217 | }
218 |
219 | default:
220 | return state;
221 | }
222 | },
223 | { status: 'waiting' }
224 | );
225 |
226 | const DiningHakkers = () =>
227 | createSetupBehavior(
228 | (state, ctx) => {
229 | const chopsticks = Array(5)
230 | .fill(null)
231 | .map((_, i) => {
232 | return ctx.spawn(Chopstick(), `Chopstick ${i}`);
233 | });
234 |
235 | const hakkers = ['A', 'B', 'C', 'D', 'E'].map((name, i) => {
236 | return ctx.spawn(
237 | Hakker(name, chopsticks[i], chopsticks[(i + 1) % 5]),
238 | name
239 | );
240 | });
241 |
242 | hakkers.forEach(hakker => {
243 | ctx.send(hakker, { type: 'think' });
244 | });
245 |
246 | return undefined;
247 | },
248 | s => s,
249 | undefined
250 | );
251 |
252 | const system = createSystem(DiningHakkers(), 'diningHakkers');
253 | }, 30000);
254 | });
255 |
--------------------------------------------------------------------------------
/test/samples.ts:
--------------------------------------------------------------------------------
1 | import { ActorRef } from '../src';
2 |
3 | describe('samples', () => {
4 | it('cqrs', done => {
5 | interface ShoppingCartState {
6 | isCheckedOut: boolean;
7 | hasItem: (itemId: string) => boolean;
8 | isEmpty: boolean;
9 | updateItem: (itemId: string, quantity: number) => ShoppingCartState;
10 | removeItem: (itemId: string) => ShoppingCartState;
11 | checkout: () => ShoppingCartState;
12 | }
13 |
14 | const createShoppingCartState = (
15 | items: Map,
16 | checkoutDate?: number
17 | ): ShoppingCartState => {
18 | const state: ShoppingCartState = {
19 | isCheckedOut: checkoutDate !== undefined,
20 | hasItem: (itemId: string): boolean => {
21 | return items.has(itemId);
22 | },
23 | isEmpty: items.size === 0,
24 | updateItem: (itemId: string, quantity: number) => {
25 | if (quantity === 0) {
26 | items.delete(itemId);
27 | return state;
28 | } else {
29 | items.set(itemId, quantity);
30 | return state;
31 | }
32 | },
33 | removeItem: (itemId: string) => {
34 | items.delete(itemId);
35 | return state;
36 | },
37 | checkout: () => {
38 | return { ...state, checkoutDate: Date.now() };
39 | },
40 | // toSummary
41 | };
42 |
43 | return state;
44 | };
45 |
46 | // final case class AddItem(itemId: String, quantity: Int, replyTo: ActorRef[StatusReply[Summary]]) extends Command
47 | interface AddItemEvent {
48 | type: 'AddItem';
49 | itemId: string;
50 | quantity: number;
51 | replyTo: ActorRef;
52 | }
53 |
54 | // final case class RemoveItem(itemId: String, replyTo: ActorRef[StatusReply[Summary]]) extends Command
55 | interface RemoveItemEvent {
56 | type: 'RemoveItem';
57 | itemId: string;
58 | replyTo: ActorRef;
59 | }
60 |
61 | // final case class AdjustItemQuantity(itemId: String, quantity: Int, replyTo: ActorRef[StatusReply[Summary]])
62 | interface AdjustItemQuantityEvent {
63 | type: 'AdjustItemQuantity';
64 | itemId: string;
65 | quantity: number;
66 | replyTo: ActorRef;
67 | }
68 |
69 | interface CheckoutEvent {
70 | type: 'Checkout';
71 | replyTo: ActorRef;
72 | }
73 |
74 | interface GetEvent {
75 | type: 'Get';
76 | replyTo: ActorRef;
77 | }
78 |
79 | const openShoppingCart = (
80 | cartId: string,
81 | state: ShoppingCartState,
82 | command: AddItemEvent
83 | ) => {
84 | switch (command.type) {
85 | case 'AddItem':
86 | if (state.hasItem(command.itemId)) {
87 | }
88 | }
89 | };
90 |
91 | // private def openShoppingCart(cartId: String, state: State, command: Command): ReplyEffect[Event, State] =
92 | // command match {
93 | // case AddItem(itemId, quantity, replyTo) =>
94 | // if (state.hasItem(itemId))
95 | // Effect.reply(replyTo)(StatusReply.Error(s"Item '$itemId' was already added to this shopping cart"))
96 | // else if (quantity <= 0)
97 | // Effect.reply(replyTo)(StatusReply.Error("Quantity must be greater than zero"))
98 | // else
99 | // Effect
100 | // .persist(ItemAdded(cartId, itemId, quantity))
101 | // .thenReply(replyTo)(updatedCart => StatusReply.Success(updatedCart.toSummary))
102 |
103 | // case RemoveItem(itemId, replyTo) =>
104 | // if (state.hasItem(itemId))
105 | // Effect
106 | // .persist(ItemRemoved(cartId, itemId))
107 | // .thenReply(replyTo)(updatedCart => StatusReply.Success(updatedCart.toSummary))
108 | // else
109 | // Effect.reply(replyTo)(StatusReply.Success(state.toSummary)) // removing an item is idempotent
110 |
111 | // case AdjustItemQuantity(itemId, quantity, replyTo) =>
112 | // if (quantity <= 0)
113 | // Effect.reply(replyTo)(StatusReply.Error("Quantity must be greater than zero"))
114 | // else if (state.hasItem(itemId))
115 | // Effect
116 | // .persist(ItemQuantityAdjusted(cartId, itemId, quantity))
117 | // .thenReply(replyTo)(updatedCart => StatusReply.Success(updatedCart.toSummary))
118 | // else
119 | // Effect.reply(replyTo)(
120 | // StatusReply.Error(s"Cannot adjust quantity for item '$itemId'. Item not present on cart"))
121 |
122 | // case Checkout(replyTo) =>
123 | // if (state.isEmpty)
124 | // Effect.reply(replyTo)(StatusReply.Error("Cannot checkout an empty shopping cart"))
125 | // else
126 | // Effect
127 | // .persist(CheckedOut(cartId, Instant.now()))
128 | // .thenReply(replyTo)(updatedCart => StatusReply.Success(updatedCart.toSummary))
129 |
130 | // case Get(replyTo) =>
131 | // Effect.reply(replyTo)(state.toSummary)
132 | // }
133 |
134 | // private def checkedOutShoppingCart(cartId: String, state: State, command: Command): ReplyEffect[Event, State] =
135 | // command match {
136 | // case Get(replyTo) =>
137 | // Effect.reply(replyTo)(state.toSummary)
138 | // case cmd: AddItem =>
139 | // Effect.reply(cmd.replyTo)(StatusReply.Error("Can't add an item to an already checked out shopping cart"))
140 | // case cmd: RemoveItem =>
141 | // Effect.reply(cmd.replyTo)(StatusReply.Error("Can't remove an item from an already checked out shopping cart"))
142 | // case cmd: AdjustItemQuantity =>
143 | // Effect.reply(cmd.replyTo)(StatusReply.Error("Can't adjust item on an already checked out shopping cart"))
144 | // case cmd: Checkout =>
145 | // Effect.reply(cmd.replyTo)(StatusReply.Error("Can't checkout already checked out shopping cart"))
146 | // }
147 |
148 | // private def handleEvent(state: State, event: Event) = {
149 | // event match {
150 | // case ItemAdded(_, itemId, quantity) => state.updateItem(itemId, quantity)
151 | // case ItemRemoved(_, itemId) => state.removeItem(itemId)
152 | // case ItemQuantityAdjusted(_, itemId, quantity) => state.updateItem(itemId, quantity)
153 | // case CheckedOut(_, eventTime) => state.checkout(eventTime)
154 | // }
155 | // }
156 |
157 | // const openShoppingCart = ()
158 | });
159 | });
160 |
--------------------------------------------------------------------------------
/test/todo.test.ts:
--------------------------------------------------------------------------------
1 | import { ActorRef, createBehavior, createSystem } from '../src';
2 |
3 | describe('todo example', () => {
4 | it('works', done => {
5 | interface TodoState {
6 | message: string;
7 | status: 'pending' | 'complete';
8 | }
9 |
10 | type TodoEvent = { type: 'update'; message: string } | { type: 'toggle' };
11 |
12 | const Todos = () =>
13 | createBehavior<
14 | | {
15 | type: 'add';
16 | message: string;
17 | }
18 | | { type: 'update'; index: number; message: string }
19 | | { type: 'toggle'; index: number },
20 | {
21 | todos: ActorRef[];
22 | }
23 | >(
24 | (state, msg, ctx) => {
25 | ctx.log(state, msg);
26 | switch (msg.type) {
27 | case 'add':
28 | return {
29 | ...state,
30 | todos: state.todos.concat(
31 | ctx.spawn(Todo(msg.message), `todo-${state.todos.length}`)
32 | ),
33 | };
34 | case 'update': {
35 | const todo = state.todos[msg.index];
36 |
37 | if (todo) {
38 | todo.send(msg);
39 | }
40 | return state;
41 | }
42 | case 'toggle': {
43 | const todo = state.todos[msg.index];
44 |
45 | if (todo) {
46 | todo.send(msg);
47 | }
48 | return state;
49 | }
50 | default:
51 | return state;
52 | }
53 | },
54 | {
55 | todos: [],
56 | }
57 | );
58 |
59 | const Todo = (message: string) =>
60 | createBehavior(
61 | (state, msg) => {
62 | switch (msg.type) {
63 | case 'update':
64 | if (state.status === 'complete') {
65 | return state;
66 | }
67 | return { ...state, message: msg.message };
68 | case 'toggle':
69 | return {
70 | ...state,
71 | status: state.status === 'pending' ? 'complete' : 'pending',
72 | };
73 | default:
74 | return state;
75 | }
76 | },
77 | {
78 | message,
79 | status: 'pending',
80 | }
81 | );
82 |
83 | const todoSystem = createSystem(Todos(), 'todos');
84 |
85 | let todo: ActorRef;
86 |
87 | todoSystem.subscribe(state => {
88 | if (state.todos.length && !todo) {
89 | todo = state.todos[0];
90 | todo.subscribe(state => {
91 | if (
92 | state.message === 'mission accomplished' &&
93 | state.status === 'complete'
94 | ) {
95 | done();
96 | }
97 | });
98 | todo.send({
99 | type: 'update',
100 | message: 'mission accomplished',
101 | });
102 |
103 | todo.send({
104 | type: 'toggle',
105 | });
106 | }
107 | });
108 |
109 | todoSystem.send({ type: 'add', message: 'hello' });
110 | });
111 | });
112 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["src", "types"],
3 | "compilerOptions": {
4 | "module": "esnext",
5 | "lib": ["dom", "esnext"],
6 | "importHelpers": true,
7 | "declaration": true,
8 | "sourceMap": true,
9 | "rootDir": "./src",
10 | "strict": true,
11 | "noUnusedLocals": true,
12 | "noUnusedParameters": true,
13 | "noImplicitReturns": true,
14 | "noFallthroughCasesInSwitch": true,
15 | "moduleResolution": "node",
16 | "baseUrl": "./",
17 | "paths": {
18 | "*": ["node_modules/*"]
19 | },
20 | "jsx": "react",
21 | "esModuleInterop": true
22 | }
23 | }
24 |
--------------------------------------------------------------------------------