├── .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 | XState 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 | --------------------------------------------------------------------------------