├── .editorconfig ├── .eslintrc.cjs ├── .github └── workflows │ └── npm.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── NOTICE ├── README.md ├── docs └── images │ ├── actor-mailbox-architecture-diagram.svg │ ├── duckula.png │ ├── event-message-tell-ask-pattern.svg │ ├── fsm-vs-actor.png │ ├── mailbox.png │ └── mailbox.svg ├── examples ├── mailbox-demo.ts └── remote-state-chart.ts ├── package.json ├── scripts ├── generate-version.sh ├── npm-pack-testing.sh └── package-publish-config-tag.sh ├── src ├── actions │ ├── idle.spec.ts │ ├── idle.ts │ ├── mod.ts │ ├── proxy.spec.ts │ ├── proxy.ts │ ├── reply.spec.ts │ ├── reply.ts │ ├── send.spec.ts │ └── send.ts ├── config.ts ├── context │ ├── child │ │ ├── actor-reply.spec.ts │ │ ├── actor-reply.ts │ │ ├── mod.ts │ │ ├── session-id.spec.ts │ │ ├── session-id.ts │ │ ├── snapshot.spec.ts │ │ └── snapshot.ts │ ├── cond │ │ ├── is-child-busy-acceptable.spec.ts │ │ ├── is-child-busy-acceptable.ts │ │ ├── is-event-from.spec.ts │ │ ├── is-event-from.ts │ │ └── mod.ts │ ├── context.ts │ ├── initial-context.ts │ ├── mod.ts │ ├── origin │ │ ├── any-event-object-meta.ts │ │ ├── meta-origin.ts │ │ ├── mod.ts │ │ ├── unwrap-event.ts │ │ └── wrap-event.ts │ ├── queue │ │ ├── dequeue.spec.ts │ │ ├── dequeue.ts │ │ ├── empty-queue.spec.ts │ │ ├── empty-queue.ts │ │ ├── enqueue.spec.ts │ │ ├── enqueue.ts │ │ ├── message.ts │ │ ├── mod.ts │ │ ├── new-message.ts │ │ ├── size.spec.ts │ │ └── size.ts │ └── request │ │ ├── address.spec.ts │ │ ├── address.ts │ │ ├── message.spec.ts │ │ ├── message.ts │ │ └── mod.ts ├── duck │ ├── event-fancy-enum.ts │ ├── events.ts │ ├── mod.ts │ ├── state-fancy-enum.ts │ ├── states.ts │ ├── type-fancy-enum.ts │ └── types.ts ├── duckula │ ├── duckula.ts │ ├── duckularize-options.spec.ts │ ├── duckularize-options.ts │ ├── duckularize.spec.ts │ ├── duckularize.ts │ ├── mod.ts │ ├── selector.spec.ts │ └── selector.ts ├── from.ts ├── impls │ ├── address-implementation.ts │ ├── address-interface.spec.ts │ ├── address-interface.ts │ ├── get-actor-machine.spec.ts │ ├── get-actor-machine.ts │ ├── mailbox-implementation.spec.ts │ ├── mailbox-implementation.ts │ └── mod.ts ├── interface.ts ├── is │ ├── is-address.spec.ts │ ├── is-address.ts │ ├── is-mailbox-type.ts │ ├── is-mailbox.spec.ts │ ├── is-mailbox.ts │ └── mod.ts ├── mailbox-id.spec.ts ├── mailbox-id.ts ├── mods │ ├── helpers.ts │ ├── impls.ts │ ├── mod.spec.ts │ └── mod.ts ├── nil.ts ├── testing-utils.ts ├── validate.spec.ts ├── validate.ts ├── version.spec.ts ├── version.ts ├── wrap.spec.ts └── wrap.ts ├── tests ├── fixtures │ └── smoke-testing.ts ├── integration.spec.ts ├── machine-behaviors │ ├── baby-machine.spec.ts │ ├── baby-machine.ts │ ├── coffee-maker-machine.spec.ts │ ├── coffee-maker-machine.ts │ ├── ding-dong-machine.spec.ts │ ├── ding-dong-machine.ts │ ├── nested-mailbox-machine.spec.ts │ └── nested-mailbox-machine.ts ├── multiple-outbound-communications.spec.ts ├── proxy-event-to-mailbox.spec.ts └── xstate-behaviors │ ├── xstate-actions-order.spec.ts │ ├── xstate-child-exit-order.spec.ts │ ├── xstate-interpreter-children.spec.ts │ ├── xstate-interpreter-session-id.spec.ts │ ├── xstate-send-address.spec.ts │ └── xstate-state-can.spec.ts ├── tsconfig.cjs.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = 0 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const rules = { 2 | "array-bracket-spacing": [ 3 | 'error', 4 | 'always', 5 | ], 6 | } 7 | 8 | module.exports = { 9 | extends: [ 10 | '@chatie', 11 | ], 12 | rules, 13 | "globals": { 14 | "NodeJS": true 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/npm.yml: -------------------------------------------------------------------------------- 1 | name: NPM 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | strategy: 9 | matrix: 10 | os: 11 | - ubuntu-latest 12 | - macos-latest 13 | - windows-latest 14 | node-version: 15 | - 16 16 | 17 | runs-on: ${{ matrix.os }} 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: npm 25 | cache-dependency-path: package.json 26 | 27 | - name: Install Dependencies 28 | run: npm install 29 | 30 | - name: Test 31 | run: npm test 32 | 33 | pack: 34 | name: Pack 35 | needs: build 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v2 39 | - uses: actions/setup-node@v2 40 | with: 41 | node-version: 16 42 | cache: npm 43 | cache-dependency-path: package.json 44 | 45 | - name: Install Dependencies 46 | run: npm install 47 | 48 | - name: Generate Version 49 | run: ./scripts/generate-version.sh 50 | 51 | - name: Pack Testing 52 | run: ./scripts/npm-pack-testing.sh 53 | 54 | publish: 55 | if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v')) 56 | name: Publish 57 | needs: [build, pack] 58 | runs-on: ubuntu-latest 59 | steps: 60 | - uses: actions/checkout@v2 61 | - uses: actions/setup-node@v2 62 | with: 63 | node-version: 16 64 | registry-url: https://registry.npmjs.org/ 65 | cache: npm 66 | cache-dependency-path: package.json 67 | 68 | - name: Install Dependencies 69 | run: npm install 70 | 71 | - name: Generate Version 72 | run: ./scripts/generate-version.sh 73 | 74 | - name: Set Publish Config 75 | run: ./scripts/package-publish-config-tag.sh 76 | 77 | - name: Build Dist 78 | run: npm run dist 79 | 80 | - name: Check Branch 81 | id: check-branch 82 | run: | 83 | if [[ ${{ github.ref }} =~ ^refs/heads/(main|v[0-9]+\.[0-9]+.*)$ ]]; then 84 | echo ::set-output name=match::true 85 | fi # See: https://stackoverflow.com/a/58869470/1123955 86 | - name: Is A Publish Branch 87 | if: steps.check-branch.outputs.match == 'true' 88 | run: | 89 | NAME=$(npx pkg-jq -r .name) 90 | VERSION=$(npx pkg-jq -r .version) 91 | if npx version-exists "$NAME" "$VERSION" 92 | then echo "$NAME@$VERSION exists on NPM, skipped." 93 | else npm publish 94 | fi 95 | env: 96 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 97 | - name: Is Not A Publish Branch 98 | if: steps.check-branch.outputs.match != 'true' 99 | run: echo 'Not A Publish Branch' 100 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | package-lock.json 63 | t.* 64 | t/ 65 | dist/ 66 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | 4 | "editor.fontFamily": "Consolas, 'Courier New', monospace", 5 | "editor.fontLigatures": true, 6 | 7 | "editor.tokenColorCustomizations": { 8 | "textMateRules": [ 9 | { 10 | "scope": [ 11 | //following will be in italics (=Pacifico) 12 | "comment", 13 | // "entity.name.type.class", //class names 14 | "keyword", //import, export, return… 15 | "support.class.builtin.js", //String, Number, Boolean…, this, super 16 | "storage.modifier", //static keyword 17 | "storage.type.class.js", //class keyword 18 | "storage.type.function.js", // function keyword 19 | "storage.type.js", // Variable declarations 20 | "keyword.control.import.js", // Imports 21 | "keyword.control.from.js", // From-Keyword 22 | "entity.name.type.js", // new … Expression 23 | "keyword.control.flow.js", // await 24 | "keyword.control.conditional.js", // if 25 | "keyword.control.loop.js", // for 26 | "keyword.operator.new.js", // new 27 | ], 28 | "settings": { 29 | "fontStyle": "italic", 30 | }, 31 | }, 32 | { 33 | "scope": [ 34 | //following will be excluded from italics (My theme (Monokai dark) has some defaults I don't want to be in italics) 35 | "invalid", 36 | "keyword.operator", 37 | "constant.numeric.css", 38 | "keyword.other.unit.px.css", 39 | "constant.numeric.decimal.js", 40 | "constant.numeric.json", 41 | "entity.name.type.class.js" 42 | ], 43 | "settings": { 44 | "fontStyle": "", 45 | }, 46 | } 47 | ] 48 | }, 49 | "files.exclude": { 50 | "dist/": true, 51 | "doc/": true, 52 | "node_modules/": true, 53 | "package/": true, 54 | }, 55 | "alignment": { 56 | "operatorPadding": "right", 57 | "indentBase": "firstline", 58 | "surroundSpace": { 59 | "colon": [1, 1], // The first number specify how much space to add to the left, can be negative. The second number is how much space to the right, can be negative. 60 | "assignment": [1, 1], // The same as above. 61 | "arrow": [1, 1], // The same as above. 62 | "comment": 2, // Special how much space to add between the trailing comment and the code. 63 | // If this value is negative, it means don't align the trailing comment. 64 | } 65 | }, 66 | "editor.formatOnSave": false, 67 | "python.pythonPath": "python3", 68 | "eslint.validate": [ 69 | "javascript", 70 | "typescript", 71 | ], 72 | "cSpell.words": [ 73 | "Pipeable", 74 | "Serializable", 75 | "asciiart", 76 | "deserialization" 77 | ], 78 | 79 | } 80 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Mailbox is for converting a XState Machine to the REAL Actor. 2 | Copyright 2020 Huan (李卓桓) 3 | 4 | This product includes software developed at 5 | The Wechaty Organization (https://github.com/wechaty). 6 | 7 | This software contains code derived from the Stackoverflow, 8 | including various modifications by GitHub. 9 | -------------------------------------------------------------------------------- /docs/images/duckula.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huan/mailbox/088fd122eafe9cd331ae08953a78211a90cbee39/docs/images/duckula.png -------------------------------------------------------------------------------- /docs/images/fsm-vs-actor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huan/mailbox/088fd122eafe9cd331ae08953a78211a90cbee39/docs/images/fsm-vs-actor.png -------------------------------------------------------------------------------- /docs/images/mailbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huan/mailbox/088fd122eafe9cd331ae08953a78211a90cbee39/docs/images/mailbox.png -------------------------------------------------------------------------------- /examples/mailbox-demo.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | import { 4 | actions, 5 | createMachine, 6 | interpret, 7 | } from 'xstate' 8 | import assert from 'assert' 9 | 10 | import * as Mailbox from '../src/mods/mod.js' 11 | 12 | /** 13 | * We will create a machine, and test it by sending two events to it: 14 | * 1. without Mailbox, the machine will only be able to reponse the first event 15 | * 2. with Mailbox, the machine will be able to reponse all of the events, one by one. 16 | * 17 | * Obviously the Mailbox-ed machine is more conforming the Actor Model with distributed async tasks. 18 | * 19 | * Huan(202201) 20 | */ 21 | 22 | /** 23 | * Create a test machine with `withMailbox` flag 24 | * states: idle <> busy 25 | * events: TASK -> TASK_RECEIVED 26 | * 27 | * Pay attention for the following rules (#1, #2, #3) 28 | */ 29 | const demoMachine = (withMailbox = false) => createMachine<{}>({ 30 | id: 'demo', 31 | initial: 'idle', 32 | states: { 33 | idle: { 34 | /** 35 | * RULE #1: machine must put a `Mailbox.actions.idle('machine-name')` action 36 | * to the `entry` of the state which your machine can accept new messages, 37 | * so that the Mailbox can know the machine are ready to receive new messages from other actors. 38 | */ 39 | entry: actions.choose([ 40 | { 41 | cond: _ => withMailbox, 42 | actions: Mailbox.actions.idle('DemoMachine'), 43 | }, 44 | ]), 45 | on: { 46 | TASK: 'busy', 47 | '*': { 48 | /** 49 | * RULE #2: machine must use an external transision to the `idle` state 50 | * when it finished processing any messages, 51 | * to trigger the `entry` action run again. 52 | */ 53 | actions: actions.log('make sure the idle state will be re-entry with external trainsition when receiving event'), 54 | target: 'idle', 55 | }, 56 | }, 57 | }, 58 | busy: { 59 | /** 60 | * RULE #3: machine use `Mailbox.actions.reply(TASK_RECEIVED)` 61 | * to reply TASK_RECEIVED (or any EVENTs) to other actors. 62 | */ 63 | entry: [ 64 | actions.choose([ 65 | { 66 | cond: _ => withMailbox, 67 | actions: Mailbox.actions.reply('TASK_RECEIVED'), 68 | }, 69 | { 70 | actions: actions.send('TASK_RECEIVED'), 71 | }, 72 | ]), 73 | _ => console.info('TASK_RECEIVED'), 74 | ], 75 | after: { 76 | 10: 'idle', 77 | }, 78 | }, 79 | }, 80 | }) 81 | 82 | async function main () { 83 | /** 84 | * Normal machine without Mailbox 85 | */ 86 | const rawMachine = demoMachine(false) 87 | const rawInterpreter = interpret(rawMachine, { logger: () => {} }) 88 | rawInterpreter.start() 89 | 90 | /** 91 | * machine with Mailbox (async queue protocol support) 92 | */ 93 | const mailbox = Mailbox.from(demoMachine(true), { logger: () => {} }) 94 | mailbox.open() 95 | 96 | /** 97 | * Issue #10 - https://github.com/huan/mailbox/issues/10 98 | * Mailbox need a reply address when it received a message, or it will send the reply to DEAD_LETTER 99 | */ 100 | const actorMachine = createMachine<{}>({ 101 | id: 'demo', 102 | initial: 'idle', 103 | states: { 104 | idle: { 105 | on: { 106 | TASK: { 107 | actions: [ 108 | _ => console.info('TASK received by actor'), 109 | actions.send(_ => ({ type: 'TASK' }), { to: mailbox.id }), 110 | ], 111 | }, 112 | TASK_RECEIVED: { 113 | actions:[ 114 | // actions.log(_ => 'TASK_RECEIVED received by actor'), 115 | _ => console.info('TASK_RECEIVED received by actor'), 116 | ], 117 | }, 118 | }, 119 | }, 120 | }, 121 | }) 122 | const actor = interpret(actorMachine, { logger: () => {} }) 123 | actor.start() 124 | 125 | /** 126 | * send two events for testing/demonstration 127 | */ 128 | const callTwice = async (send: () => void) => { 129 | ;[ ...Array(2).keys() ].forEach(i => { 130 | console.info(`> sending event #${i}`) 131 | send() 132 | }) 133 | await new Promise(resolve => setTimeout(resolve, 30)) 134 | } 135 | 136 | /** 137 | * For normal machine, it will only response the first event 138 | */ 139 | console.info('####################################################################') 140 | console.info('# testing the raw machine ... (a raw machine will only be able to response the first event)') 141 | let taskCounter = 0 142 | rawInterpreter.onEvent(event => event.type === 'TASK_RECEIVED' && taskCounter++) 143 | await callTwice(rawInterpreter.sender('TASK')) 144 | console.info(`# testing raw machine: taskCounter = ${taskCounter}\n`) 145 | assert(taskCounter === 1, 'should get 1 TASK_RECEIVED event') 146 | 147 | /** 148 | * for a Mailbox-ed machine(actor), it will response all events by processing it one by one. 149 | */ 150 | console.info('####################################################################') 151 | console.info('# testing the mailbox-ed(actor) machine ... (an actor will be able to response two events one by one)') 152 | taskCounter = 0 153 | actor.onEvent(event => event.type === 'TASK_RECEIVED' && taskCounter++) 154 | await callTwice(() => actor.send('TASK')) 155 | console.info(`# testing mailbox-ed machine: taskCounter = ${taskCounter}\n`) 156 | assert(taskCounter === 2, 'should get 2 TASK_RECEIVED events') 157 | 158 | /** 159 | * Conclusion: 160 | * a state machine has internal state transtions and it might not be able to response the new messages at a time, 161 | * which means we need to have a mailbox to store the messages and process them one by one. 162 | * 163 | * This is the reason of why we built the Mailbox for XState for using it as a Actor Model with distributed async tasks. 164 | */ 165 | } 166 | 167 | main() 168 | .catch(console.error) 169 | -------------------------------------------------------------------------------- /examples/remote-state-chart.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | /** 4 | * Huan(202201): 5 | * 6 | * Issue #10: Remote inspecting XState with State Chart for our Actors 7 | * @link https://github.com/wechaty/bot5-assistant/issues/10 8 | * 9 | * Usage: 10 | * 1. run this file 11 | * 2. open `https://statecharts.io/inspect?server=localhost:8888` 12 | * 13 | * Note: must be localhost because of the `ws` protoco in this demo program is not TLS 14 | */ 15 | 16 | import { 17 | createMachine, 18 | actions, 19 | interpret, 20 | } from 'xstate' 21 | import { inspect } from '@xstate/inspect/lib/server.js' 22 | import { WebSocketServer } from 'ws' 23 | 24 | enum State { 25 | inactive = 'pingpong/inactive', 26 | active = 'pingpong/active', 27 | } 28 | const states = State 29 | 30 | enum Type { 31 | PING = 'pingpong/PING', 32 | PONG = 'pingpong/PONG', 33 | } 34 | const types = Type 35 | 36 | const events = { 37 | PING: () => ({ type: types.PING }) as const, 38 | PONG: () => ({ type: types.PONG }) as const, 39 | } as const 40 | type Events = typeof events 41 | type Event = ReturnType 42 | 43 | interface Context {} 44 | 45 | const PONGER_ID = 'ponger' 46 | 47 | /** 48 | * Testing ping pong machine 49 | * 50 | * @link https://github.com/statelyai/xstate/blob/main/packages/xstate-inspect/examples/server.ts 51 | */ 52 | const machine = createMachine({ 53 | initial: states.inactive, 54 | invoke: { 55 | id: PONGER_ID, 56 | src: () => (send, onReceive) => { 57 | onReceive((event) => { 58 | if (event.type === types.PING) { 59 | send(events.PONG()) 60 | } 61 | }) 62 | }, 63 | }, 64 | states: { 65 | [states.inactive]: { 66 | after: { 67 | 1000: states.active, 68 | }, 69 | }, 70 | [states.active]: { 71 | entry: actions.send(events.PING(), { 72 | delay: 1000, 73 | to: PONGER_ID, 74 | }), 75 | on: { 76 | [types.PONG]: states.inactive, 77 | }, 78 | }, 79 | }, 80 | }) 81 | 82 | const server = new WebSocketServer({ 83 | port: 8888, 84 | }) 85 | 86 | inspect({ server }) 87 | 88 | const interpreter = interpret(machine, { devTools: true }) 89 | 90 | interpreter.start() 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mailbox", 3 | "version": "0.11.2", 4 | "description": "Mailbox is for converting a XState Machine to an Actor that can deal with concurrency requests", 5 | "type": "module", 6 | "exports": { 7 | ".": { 8 | "import": "./dist/esm/src/mods/mod.js", 9 | "require": "./dist/cjs/src/mods/mod.js" 10 | }, 11 | "./helpers": { 12 | "import": "./dist/esm/src/mods/helpers.js", 13 | "require": "./dist/cjs/src/mods/helpers.js" 14 | }, 15 | "./impls": { 16 | "import": "./dist/esm/src/mods/impls.js", 17 | "require": "./dist/cjs/src/mods/impls.js" 18 | } 19 | }, 20 | "typesVersions": { 21 | "*": { 22 | "helpers": [ 23 | "./dist/esm/src/mods/helpers.d.ts" 24 | ], 25 | "impls": [ 26 | "./dist/esm/src/mods/impls.d.ts" 27 | ] 28 | } 29 | }, 30 | "typings": "./dist/esm/src/mods/mod.d.ts", 31 | "engines": { 32 | "node": ">=16", 33 | "npm": ">=8" 34 | }, 35 | "scripts": { 36 | "clean": "shx rm -fr dist/*", 37 | "demo": "cross-env NODE_OPTIONS=\"--no-warnings --loader=ts-node/esm\" node \"examples/mailbox-demo.ts\"", 38 | "dist": "npm-run-all clean build dist:commonjs", 39 | "build": "tsc && tsc -p tsconfig.cjs.json", 40 | "dist:commonjs": "jq -n \"{ type: \\\"commonjs\\\" }\" > dist/cjs/package.json", 41 | "lint": "npm-run-all lint:es lint:ts", 42 | "lint:ts": "tsc --isolatedModules --noEmit", 43 | "test": "npm-run-all lint test:unit", 44 | "test:unit": "cross-env NODE_OPTIONS=\"--no-warnings --loader=ts-node/esm\" tap \"src/**/*.spec.ts\" \"tests/**/*.spec.ts\"", 45 | "test:pack": "bash -x scripts/npm-pack-testing.sh", 46 | "lint:es": "eslint --ignore-pattern fixtures/ \"src/**/*.ts\" \"tests/**/*.ts\"" 47 | }, 48 | "repository": { 49 | "type": "git", 50 | "url": "git+https://github.com/huan/mailbox.git" 51 | }, 52 | "keywords": [ 53 | "actor", 54 | "xstate", 55 | "fsm", 56 | "event driven" 57 | ], 58 | "author": "Huan LI ", 59 | "license": "Apache-2.0", 60 | "bugs": { 61 | "url": "https://github.com/huan/mailbox/issues" 62 | }, 63 | "homepage": "https://github.com/huan/mailbox#readme", 64 | "devDependencies": { 65 | "@chatie/eslint-config": "^1.0.4", 66 | "@chatie/git-scripts": "^0.7.7", 67 | "@chatie/semver": "^0.4.7", 68 | "@chatie/tsconfig": "^4.9.1", 69 | "@types/ws": "^8.5.3", 70 | "@xstate/inspect": "0.6.5", 71 | "is-observable": "^2.1.0", 72 | "pkg-jq": "^0.2.11", 73 | "read-pkg-up": "^8.0.0", 74 | "ws": "^8.6.0" 75 | }, 76 | "dependencies": { 77 | "rxjs": "^7.5.5", 78 | "symbol-observable": "^4.0.0", 79 | "typesafe-actions": "^5.1.0", 80 | "utility-types": "^3.10.0", 81 | "xstate": "4.31.0" 82 | }, 83 | "files": [ 84 | "dist", 85 | "src" 86 | ], 87 | "publishConfig": { 88 | "access": "public", 89 | "tag": "next" 90 | }, 91 | "git": { 92 | "scripts": { 93 | "pre-push": "npx git-scripts-pre-push" 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /scripts/generate-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | SRC_VERSION_TS_FILE='src/version.ts' 5 | 6 | [ -f ${SRC_VERSION_TS_FILE} ] || { 7 | echo ${SRC_VERSION_TS_FILE}" not found" 8 | exit 1 9 | } 10 | 11 | VERSION=$(npx pkg-jq -r .version) 12 | 13 | cat <<_SRC_ > ${SRC_VERSION_TS_FILE} 14 | /** 15 | * This file was auto generated from scripts/generate-version.sh 16 | */ 17 | export const VERSION: string = '${VERSION}' 18 | _SRC_ 19 | 20 | echo "$VERSION generated." 21 | -------------------------------------------------------------------------------- /scripts/npm-pack-testing.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | npm run dist 5 | npm pack 6 | 7 | TMPDIR="/tmp/npm-pack-testing.$$" 8 | mkdir "$TMPDIR" 9 | mv ./*-*.*.*.tgz "$TMPDIR" 10 | cp tests/fixtures/smoke-testing.ts "$TMPDIR" 11 | 12 | cd $TMPDIR 13 | 14 | npm init -y 15 | npm install ./*-*.*.*.tgz \ 16 | @types/node \ 17 | typescript \ 18 | 19 | # 20 | # CommonJS 21 | # 22 | ./node_modules/.bin/tsc \ 23 | --esModuleInterop \ 24 | --lib esnext \ 25 | --noEmitOnError \ 26 | --noImplicitAny \ 27 | --skipLibCheck \ 28 | --target es5 \ 29 | --module CommonJS \ 30 | --moduleResolution node \ 31 | smoke-testing.ts 32 | 33 | echo 34 | echo "CommonJS: pack testing..." 35 | node smoke-testing.js 36 | 37 | # 38 | # ES Modules 39 | # 40 | 41 | # https://stackoverflow.com/a/59203952/1123955 42 | echo "`jq '.type="module"' package.json`" > package.json 43 | 44 | ./node_modules/.bin/tsc \ 45 | --esModuleInterop \ 46 | --lib esnext \ 47 | --noEmitOnError \ 48 | --noImplicitAny \ 49 | --skipLibCheck \ 50 | --target es2020 \ 51 | --module es2020 \ 52 | --moduleResolution node \ 53 | smoke-testing.ts 54 | 55 | echo 56 | echo "ES Module: pack testing..." 57 | node smoke-testing.js 58 | -------------------------------------------------------------------------------- /scripts/package-publish-config-tag.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | VERSION=$(npx pkg-jq -r .version) 5 | 6 | if npx --package @chatie/semver semver-is-prod $VERSION; then 7 | npx pkg-jq -i '.publishConfig.tag="latest"' 8 | echo "production release: publicConfig.tag set to latest." 9 | else 10 | npx pkg-jq -i '.publishConfig.tag="next"' 11 | echo 'development release: publicConfig.tag set to next.' 12 | fi 13 | 14 | -------------------------------------------------------------------------------- /src/actions/idle.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | import { 3 | test, 4 | } from 'tstest' 5 | 6 | import { idle } from './mod.js' 7 | 8 | test('idle()', async t => { 9 | t.ok(idle, 'tbw') 10 | }) 11 | -------------------------------------------------------------------------------- /src/actions/idle.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | /* eslint-disable sort-keys */ 21 | import { actions } from 'xstate' 22 | 23 | import * as duck from '../duck/mod.js' 24 | import * as is from '../is/mod.js' 25 | 26 | export const idle = (id: string) => { 27 | const moduleName = `${id}` 28 | 29 | return actions.choose([ 30 | { 31 | /** 32 | * If the transition event is a Mailbox type events (system messages): 33 | * then do not trigger DISPATCH event 34 | * because only non-mailbox-type events need to check QUEUE 35 | */ 36 | cond: (_, e) => is.isMailboxType(e.type), 37 | actions: [ 38 | actions.log((_, e) => `actions.idle [${e.type}] ignored becasue its an internal Mailbox event`, moduleName), 39 | ], 40 | }, 41 | { 42 | /** 43 | * send ACTOR_IDLE event to the mailbox for receiving new messages 44 | */ 45 | actions: [ 46 | actions.log('actions.idle sendParent [ACTOR_IDLE]', moduleName), 47 | actions.sendParent(duck.Event.ACTOR_IDLE()), 48 | ], 49 | }, 50 | ]) 51 | } 52 | -------------------------------------------------------------------------------- /src/actions/mod.ts: -------------------------------------------------------------------------------- 1 | export * from './idle.js' 2 | export * from './proxy.js' 3 | export * from './reply.js' 4 | export * from './send.js' 5 | -------------------------------------------------------------------------------- /src/actions/proxy.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | import { 3 | test, 4 | } from 'tstest' 5 | 6 | import { proxy } from './proxy.js' 7 | 8 | test('proxy()', async t => { 9 | t.ok(proxy, 'tbw') 10 | }) 11 | -------------------------------------------------------------------------------- /src/actions/proxy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | /* eslint-disable sort-keys */ 21 | import { actions } from 'xstate' 22 | 23 | import * as is from '../is/mod.js' 24 | import type * as impls from '../impls/mod.js' 25 | import * as context from '../context/mod.js' 26 | 27 | import { send } from './send.js' 28 | import { mailboxId } from '../mailbox-id.js' 29 | 30 | /** 31 | * Send (proxy) all events to target. 32 | * 33 | * It will skip the below two type of events: 34 | * 1. Mailbox type 35 | * 2. send from Child 36 | * 37 | * And send all other events to the target address, 38 | * by setting the `origin` to the current machine address. 39 | * 40 | * @param machineName Self Machine Name 41 | * @param toAddress {string | Address | Mailbox} the target address 42 | * - string: the sessionId of the interpreter, or invoke.id of the child machine 43 | */ 44 | export const proxy = (machineName: string) => (toAddress: string | impls.Address | impls.Mailbox) => { 45 | const MAILBOX_NAME = mailboxId(machineName) 46 | 47 | return actions.choose([ 48 | { 49 | /** 50 | * 1. Mailbox.Types.* is system messages, do not proxy them 51 | */ 52 | cond: (_, e) => is.isMailboxType(e.type), 53 | actions: actions.log((_, e, { _event }) => `actions.proxy [${e.type}]@${_event.origin} ignored because its an internal MailboxType`, MAILBOX_NAME), 54 | }, 55 | { 56 | /** 57 | * 2. Child events (origin from child machine) are handled by child machine, skip them 58 | */ 59 | cond: context.cond.isEventFrom(toAddress), 60 | actions: actions.log((_, e, { _event }) => `actions.proxy [${e.type}]@${_event.origin} ignored because it is sent from the actor (child/target) machine`, MAILBOX_NAME), 61 | }, 62 | { 63 | /** 64 | * 3. Proxy it 65 | */ 66 | actions: [ 67 | actions.log((_, e, { _event }) => `actions.proxy [${e.type}]@${_event.origin} -> ${toAddress}`, MAILBOX_NAME), 68 | send(toAddress)((_, e) => e), 69 | ], 70 | }, 71 | ]) 72 | } 73 | -------------------------------------------------------------------------------- /src/actions/reply.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | import { 3 | test, 4 | } from 'tstest' 5 | import { 6 | actions, 7 | AnyEventObject, 8 | createMachine, 9 | interpret, 10 | } from 'xstate' 11 | 12 | import * as duck from '../duck/mod.js' 13 | 14 | import { reply } from './reply.js' 15 | 16 | /** 17 | * Issue #11 - Race condition: Mailbox think the target machine is busy when it's not 18 | * @link https://github.com/wechaty/bot5-assistant/issues/11 19 | */ 20 | test('reply()', async t => { 21 | const targetMachine = createMachine({ 22 | id: 'target-machine-id', 23 | initial: 'idle', 24 | states: { 25 | idle: { 26 | entry: [ 27 | reply('FIRST_LINE'), 28 | actions.sendParent('SECOND_LINE'), 29 | ], 30 | }, 31 | }, 32 | }) 33 | 34 | const consumerMachine = createMachine({ 35 | invoke: { 36 | src: targetMachine, 37 | }, 38 | }) 39 | 40 | const interpreter = interpret(consumerMachine) 41 | const eventList: AnyEventObject[] = [] 42 | interpreter 43 | .onEvent(e => eventList.push(e)) 44 | .start() 45 | 46 | await new Promise(resolve => setTimeout(resolve, 0)) 47 | t.same( 48 | eventList, 49 | [ 50 | { type: 'xstate.init' }, 51 | { type: 'SECOND_LINE' }, 52 | duck.Event.ACTOR_REPLY({ type: 'FIRST_LINE' }), 53 | ], 54 | 'should send reply event to parent in the next event loop', 55 | ) 56 | }) 57 | -------------------------------------------------------------------------------- /src/actions/reply.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import { actions, SendActionOptions } from 'xstate' 21 | 22 | import * as events from '../duck/events.js' 23 | 24 | /** 25 | * Huan(202112): for child, respond the mailbox implict or explicit? 26 | * 27 | * 1. implict: all events are sent to the mailbox and be treated as the reply to the current message 28 | * 2. explicit: only the events that are explicitly sent to the mailbox via `sendParent`, are treated as the reply to the current message 29 | * 30 | * Current: explicit(2). (see: contexts.respondChildMessage) 31 | */ 32 | export const reply: typeof actions.sendParent = (event, options) => { 33 | /** 34 | * Huan(202201): Issue #11 - Race condition: Mailbox think the target machine is busy when it's not 35 | * @link https://github.com/wechaty/bot5-assistant/issues/11 36 | * 37 | * add a `delay:0` when sending reply events to put the send action to the next tick 38 | */ 39 | const delayedOptions: SendActionOptions = { 40 | delay: 0, 41 | ...options, 42 | } 43 | 44 | if (typeof event === 'function') { 45 | return actions.sendParent( 46 | (ctx, e, meta) => events.ACTOR_REPLY(event(ctx, e, meta)), 47 | delayedOptions, 48 | ) 49 | } else if (typeof event === 'string') { 50 | return actions.sendParent( 51 | events.ACTOR_REPLY({ type: event }), 52 | delayedOptions, 53 | ) 54 | } else { 55 | return actions.sendParent( 56 | /** 57 | * Huan(202112) FIXME: remove any 58 | * 59 | * How to fix TS2322: "could be instantiated with a different subtype of constraint 'object'"? 60 | * @link https://stackoverflow.com/a/56701587/1123955 61 | * 62 | * 'PayloadAction<"mailbox/ACTOR_REPLY", { message: EventObject; }>' 63 | * is assignable to the constraint of type 'TSentEvent', 64 | * but 'TSentEvent' could be instantiated with a different subtype of 65 | * constraint 'EventObject'.ts(2322) 66 | */ 67 | events.ACTOR_REPLY(event) as any, 68 | delayedOptions, 69 | ) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/actions/send.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | import { test, sinon } from 'tstest' 3 | import { 4 | actions, 5 | createMachine, 6 | interpret, 7 | } from 'xstate' 8 | 9 | import { send } from './send.js' 10 | 11 | test('send() with string address', async t => { 12 | const sandbox = sinon.createSandbox({ 13 | useFakeTimers: true, 14 | }) 15 | 16 | const machineA = createMachine({ 17 | id: 'machine-a', 18 | on: { 19 | DING: { 20 | actions: [ 21 | actions.log('received DING'), 22 | actions.respond('DONG'), 23 | ], 24 | }, 25 | }, 26 | }) 27 | 28 | const eventListA = [] as any[] 29 | const interpreterA = interpret(machineA) 30 | .onEvent(e => eventListA.push(e)) 31 | .start() 32 | 33 | const sessionId = interpreterA.sessionId 34 | 35 | const machineB = createMachine({ 36 | id: 'machine-b', 37 | initial: 'idle', 38 | states: { 39 | idle: { 40 | entry: [ 41 | actions.log('entry idle'), 42 | send(sessionId)('DING'), 43 | ], 44 | }, 45 | }, 46 | }) 47 | 48 | const eventListB = [] as any[] 49 | const interpreterB = interpret(machineB) 50 | .onEvent(e => eventListB.push(e)) 51 | .start() 52 | 53 | void interpreterB 54 | await sandbox.clock.runAllAsync() 55 | 56 | // eventListA.forEach(e => console.info('A:', e)) 57 | // eventListB.forEach(e => console.info('B:', e)) 58 | 59 | t.same( 60 | eventListA, 61 | [ 62 | { type: 'xstate.init' }, 63 | { type: 'DING' }, 64 | ], 65 | 'should have received DING by machine A', 66 | ) 67 | 68 | t.same( 69 | eventListB, 70 | [ 71 | { type: 'xstate.init' }, 72 | { type: 'DONG' }, 73 | ], 74 | 'should have received DONG by machine B', 75 | ) 76 | 77 | sandbox.restore() 78 | }) 79 | -------------------------------------------------------------------------------- /src/actions/send.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import type { actions } from 'xstate' 21 | 22 | import { Address, AddressImpl, Mailbox } from '../impls/mod.js' 23 | 24 | /** 25 | * Send events to an target (Mailbox Address) 26 | * 27 | * @param { Mailbox | Address | string } toAddress destination (target) address 28 | * - string: the sessionId of the interpreter, or invoke.id of the child machine 29 | */ 30 | export const send: (toAddress: string | Address | Mailbox) => typeof actions.send 31 | = toAddress => (event, options) => 32 | AddressImpl 33 | .from(toAddress) 34 | .send(event, options) 35 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | const IS_PRODUCTION = process.env['NODE_ENV'] === 'production' 2 | const IS_DEVELOPMENT = process.env['NODE_ENV'] === 'development' 3 | 4 | export { 5 | IS_PRODUCTION, 6 | IS_DEVELOPMENT, 7 | } 8 | -------------------------------------------------------------------------------- /src/context/child/actor-reply.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { test } from 'tstest' 5 | 6 | import { actorReply } from './actor-reply.js' 7 | 8 | test('actorReply() smoke testing', async t => { 9 | t.ok(actorReply, 'tbw') 10 | }) 11 | -------------------------------------------------------------------------------- /src/context/child/actor-reply.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | /* eslint-disable sort-keys */ 21 | /** 22 | * XState Actors: 23 | * @see https://xstate.js.org/docs/guides/actors.html#actor-api 24 | */ 25 | 26 | import { actions } from 'xstate' 27 | 28 | import * as duck from '../../duck/mod.js' 29 | import { mailboxId, wrappedId } from '../../mailbox-id.js' 30 | 31 | import * as request from '../request/mod.js' 32 | import type { Context } from '../context.js' 33 | 34 | import { sessionId } from './session-id.js' 35 | 36 | /** 37 | * Send an event as response to the current processing message of Mailbox. 38 | * 39 | * send the CHILD_RESPONSE.payload.message to the child message origin 40 | */ 41 | export const actorReply = (actorId: string) => actions.choose>([ 42 | { 43 | /** 44 | * I. validate the event, make it as the reply of actor if it valid 45 | */ 46 | cond: (ctx, _, { _event, state }) => 47 | // 1. current event is sent from CHILD_MACHINE_ID 48 | (!!_event.origin && _event.origin === sessionId(wrappedId(actorId))(state.children)) 49 | // 2. the message has valid origin for which we are going to reply to 50 | && !!request.address(ctx), 51 | actions: [ 52 | actions.log((ctx, e, { _event }) => `actorReply ACTOR_REPLY [${e.payload.message.type}]@${_event.origin} -> [${request.message(ctx)?.type}]@${request.address(ctx)}`, mailboxId(actorId)), 53 | actions.send( 54 | (_, e) => e.payload.message, 55 | { to: ctx => request.address(ctx)! }, 56 | ), 57 | ], 58 | }, 59 | /** 60 | * II. send invalid event to Dead Letter Queue (DLQ) 61 | */ 62 | { 63 | actions: [ 64 | actions.log((_, e, { _event }) => `actorReply dead letter [${e.payload.message.type}]@${_event.origin}`, mailboxId(actorId)), 65 | actions.send((_, e, { _event }) => duck.Event.DEAD_LETTER( 66 | e.payload.message, 67 | `message ${e.payload.message.type}@${_event.origin} dropped`, 68 | )), 69 | ], 70 | }, 71 | ]) as any 72 | -------------------------------------------------------------------------------- /src/context/child/mod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | export { actorReply } from './actor-reply.js' 21 | export { sessionId } from './session-id.js' 22 | export { snapshot } from './snapshot.js' 23 | -------------------------------------------------------------------------------- /src/context/child/session-id.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { test } from 'tstest' 5 | 6 | import { sessionId } from './session-id.js' 7 | 8 | test('sessionId() smoke testing', async t => { 9 | t.ok(sessionId, 'tbw') 10 | }) 11 | -------------------------------------------------------------------------------- /src/context/child/session-id.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import type { ActorRef, Interpreter } from 'xstate' 21 | 22 | /** 23 | * Get session id by child id (with currying) from children 24 | * @param childId child id 25 | * @param children children 26 | * @returns session id 27 | * 28 | * If the `childId` is a not valid childId, will return `undefined` 29 | */ 30 | export const sessionId: (childId: string) => (children?: Record>) => undefined | string 31 | = childId => children => { 32 | if (!children) { 33 | return undefined 34 | } 35 | 36 | const child = children[childId] as undefined | Interpreter 37 | if (!child) { 38 | throw new Error('can not found child id ' + childId) 39 | } 40 | 41 | if (!child.sessionId) { 42 | /** 43 | * Huan(202112): 44 | * 45 | * When we are not using the interpreter, we can not get the sessionId 46 | * for example, we are usint the `machine.transition(event)` 47 | */ 48 | // console.error(new Error('can not found child sessionId from ' + CHILD_MACHINE_ID)) 49 | return undefined 50 | } 51 | 52 | return child.sessionId 53 | } 54 | -------------------------------------------------------------------------------- /src/context/child/snapshot.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { test } from 'tstest' 5 | import { createMachine, actions, interpret, AnyEventObject } from 'xstate' 6 | 7 | import { snapshot } from './snapshot.js' 8 | 9 | test('snapshot() smoke testing', async t => { 10 | const CHILD_ID = 'child-id' 11 | const child = createMachine({ 12 | id: CHILD_ID, 13 | context: { 14 | child: true, 15 | }, 16 | initial: 'idle', 17 | states: { 18 | idle: {}, 19 | }, 20 | }) 21 | 22 | const PARENT_ID = 'parent-id' 23 | const parent = createMachine({ 24 | id: PARENT_ID, 25 | invoke: { 26 | id: CHILD_ID, 27 | src: child, 28 | }, 29 | initial: 'idle', 30 | states: { 31 | idle: { 32 | on: { 33 | TEST: { 34 | actions: actions.choose([ 35 | { 36 | cond: (_, __, meta) => !!snapshot(CHILD_ID)(meta.state), 37 | actions: actions.send('SNAPSHOT_GOT'), 38 | }, 39 | ]), 40 | }, 41 | }, 42 | }, 43 | }, 44 | }) 45 | 46 | const eventList: AnyEventObject[] = [] 47 | const interpreter = interpret(parent) 48 | .onEvent(e => { 49 | eventList.push(e) 50 | }) 51 | .start() 52 | 53 | eventList.length = 0 54 | interpreter.send('TEST') 55 | await new Promise(setImmediate) 56 | 57 | t.same(eventList, [ 58 | { type: 'TEST' }, 59 | { type: 'SNAPSHOT_GOT' }, 60 | ], 'should be able to get snapshot of CHILD_ID in actions.choose of PARENT_ID') 61 | }) 62 | -------------------------------------------------------------------------------- /src/context/child/snapshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import type { EventObject, State } from 'xstate' 21 | 22 | import type { Context } from '../context' 23 | 24 | /** 25 | * Get snapshot by child id (with currying) from state 26 | */ 27 | export const snapshot: (childId: string) => (state?: State) => State | undefined 28 | = childId => state => { 29 | const child = state?.children[childId] 30 | return child?.getSnapshot() 31 | } 32 | -------------------------------------------------------------------------------- /src/context/cond/is-child-busy-acceptable.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { 5 | actions, 6 | AnyEventObject, 7 | createMachine, 8 | interpret, 9 | Interpreter, 10 | } from 'xstate' 11 | import { test } from 'tstest' 12 | 13 | import * as duck from '../../duck/mod.js' 14 | import * as MailboxActions from '../../actions/mod.js' 15 | 16 | import type { Context } from '../context.js' 17 | import * as origin from '../origin/mod.js' 18 | import * as request from '../request/mod.js' 19 | 20 | import { isChildBusyAcceptable } from './is-child-busy-acceptable.js' 21 | 22 | test('isChildBusyAcceptable()', async t => { 23 | enum Type { 24 | MATCH = 'MATCH', 25 | 26 | TEST_CAN = 'TEST_CAN', 27 | TEST_CANNOT = 'TEST_CANNOT', 28 | 29 | IDLE = 'IDLE', 30 | BUSY = 'BUSY', 31 | 32 | SET_ORIGIN = 'SET_ORIGIN', 33 | CLEAR_ORIGIN = 'CLEAR_ORIGIN', 34 | } 35 | 36 | /** 37 | * Actor Machine 38 | */ 39 | const ACTOR_ID = 'actor-id' 40 | const actorMachine = createMachine({ 41 | id: ACTOR_ID, 42 | on: { 43 | BUSY: duck.State.Busy, 44 | IDLE: duck.State.Idle, 45 | }, 46 | 47 | initial: duck.State.Idle, 48 | states: { 49 | [duck.State.Idle]: { 50 | entry: actions.log('states.Idle', ACTOR_ID), 51 | }, 52 | [duck.State.Busy]: { 53 | entry: actions.log('states.Busy', ACTOR_ID), 54 | on: { 55 | [Type.TEST_CAN]: { 56 | actions: actions.log('TEST_CAN', ACTOR_ID), 57 | }, 58 | }, 59 | }, 60 | }, 61 | }) 62 | 63 | /** 64 | * Mailbox Machine 65 | */ 66 | const MAILBOX_ID = 'mailbox-id' 67 | const mailboxMachine = createMachine({ 68 | id: MAILBOX_ID, 69 | invoke: { 70 | /** 71 | * Error: Unable to send event to child 'xxx' from service 'yyy'. 72 | * @link https://github.com/huan/mailbox/issues/7 73 | */ 74 | id : ACTOR_ID, 75 | src : actorMachine, 76 | }, 77 | context: {} as Context, 78 | 79 | initial: duck.State.Idle, 80 | states: { 81 | [duck.State.Idle]: { 82 | entry: actions.log('states.Idle', MAILBOX_ID), 83 | on: { 84 | [Type.BUSY]: { 85 | actions: [ 86 | actions.log('on.BUSY', MAILBOX_ID), 87 | actions.send(Type.BUSY, { to: ACTOR_ID }), 88 | ], 89 | target: duck.State.Busy, 90 | }, 91 | }, 92 | }, 93 | [duck.State.Busy]: { 94 | entry: actions.log('states.Busy', MAILBOX_ID), 95 | on: { 96 | [Type.IDLE]: { 97 | actions: [ 98 | actions.log('on.IDLE', MAILBOX_ID), 99 | actions.send(Type.IDLE, { to: ACTOR_ID }), 100 | ], 101 | target: duck.State.Idle, 102 | }, 103 | [Type.SET_ORIGIN]: { 104 | actions: [ 105 | actions.log('on.SET_ORIGIN', MAILBOX_ID), 106 | actions.assign({ 107 | message: (_, e, { _event }) => origin.wrapEvent(e, _event.origin), 108 | }), 109 | actions.log((ctx, _, { _event }) => `SET_ORIGIN: ctx: ${JSON.stringify(_event)} | ${JSON.stringify(ctx)}`), 110 | actions.log(ctx => request.address(ctx as any)), 111 | ], 112 | }, 113 | [Type.CLEAR_ORIGIN]: { 114 | actions: [ 115 | actions.log('on.CLEAR_ORIGIN', MAILBOX_ID), 116 | actions.assign({ message: _ => undefined }), 117 | ], 118 | }, 119 | [Type.TEST_CAN]: { 120 | actions: [ 121 | actions.log('on.TEST_CAN', MAILBOX_ID), 122 | actions.choose([ 123 | { 124 | cond: isChildBusyAcceptable(ACTOR_ID), 125 | actions: actions.sendParent(Type.MATCH), 126 | }, 127 | ]), 128 | ], 129 | }, 130 | [Type.TEST_CANNOT]: { 131 | actions: [ 132 | actions.log('on.TEST_CANNOT', MAILBOX_ID), 133 | actions.choose([ 134 | { 135 | cond: isChildBusyAcceptable(ACTOR_ID), 136 | actions: actions.sendParent(Type.MATCH), 137 | }, 138 | ]), 139 | ], 140 | }, 141 | }, 142 | }, 143 | }, 144 | }) 145 | 146 | /** 147 | * Test Machine 148 | */ 149 | const TEST_ID = 'test-id' 150 | const testMachine = createMachine({ 151 | id: TEST_ID, 152 | invoke: { 153 | id: MAILBOX_ID, 154 | src: mailboxMachine, 155 | }, 156 | on: { 157 | '*': { 158 | actions: [ 159 | actions.log((_, e) => `on.* [${e.type}]`, TEST_ID), 160 | MailboxActions.proxy(TEST_ID)(MAILBOX_ID), 161 | ], 162 | }, 163 | }, 164 | initial: duck.State.Idle, 165 | states: { 166 | [duck.State.Idle]: { 167 | entry: actions.log('states.Idle', TEST_ID), 168 | }, 169 | }, 170 | }) 171 | 172 | const eventList: AnyEventObject[] = [] 173 | const interpreter = interpret(testMachine) 174 | .onEvent(e => eventList.push(e)) 175 | .start() 176 | 177 | const mailboxInterpreter = interpreter.children.get(MAILBOX_ID) as Interpreter 178 | const mailboxSnapshot = () => mailboxInterpreter.getSnapshot() 179 | const mailboxState = () => mailboxSnapshot().value 180 | const mailboxContext = () => mailboxSnapshot().context 181 | 182 | const actorInterpreter = mailboxInterpreter.children.get(ACTOR_ID) as Interpreter 183 | const actorSnapshot = () => actorInterpreter.getSnapshot() 184 | const actorState = () => actorSnapshot().value 185 | // const actorContext = () => actorSnapshot().context 186 | 187 | /** 188 | * no match if not inside Mailbox.State.Busy state 189 | */ 190 | eventList.length = 0 191 | t.equal(mailboxState(), duck.State.Idle, 'mailbox in State.Idle') 192 | t.equal(actorState(), duck.State.Idle, 'actor in State.Idle') 193 | interpreter.send(Type.TEST_CAN) 194 | await new Promise(resolve => setTimeout(resolve, 0)) 195 | t.same(eventList, [ 196 | { type: Type.TEST_CAN }, 197 | ], 'should no match TEST_CAN because actor is in State.Idle') 198 | 199 | /** 200 | * no match if there's no current message origin in Mailbox context 201 | */ 202 | eventList.length = 0 203 | interpreter.send(Type.BUSY) 204 | await new Promise(resolve => setTimeout(resolve, 0)) 205 | t.equal(mailboxState(), duck.State.Busy, 'should in State.Busy of mailbox') 206 | t.equal(actorState(), duck.State.Busy, 'should in state State.Busy of actor') 207 | t.notOk(mailboxContext().message, 'no message in Mailbox context') 208 | interpreter.send(Type.TEST_CAN) 209 | await new Promise(resolve => setTimeout(resolve, 0)) 210 | t.same(eventList, [ 211 | { type: Type.BUSY }, 212 | { type: Type.TEST_CAN }, 213 | ], 'should not match because child has no current message origin') 214 | 215 | // await new Promise(setImmediate) 216 | // console.info(eventList) 217 | // eventList.forEach(e => console.info(e)) 218 | 219 | /** 220 | * no match if the event type is not acceptable by child 221 | * (even inside Mailbox.State.Busy state and there's a current message origin in Mailbox context) 222 | */ 223 | t.notOk(mailboxContext().message, 'should has no message in Mailbox context before SET_ORIGIN') 224 | interpreter.send(Type.SET_ORIGIN) 225 | await new Promise(resolve => setTimeout(resolve, 0)) 226 | t.ok(mailboxContext().message, 'should has message in Mailbox context after SET_ORIGIN') 227 | 228 | t.notOk((actorInterpreter as Interpreter).state.can('TEST_CANNOT'), 'actor cannot accept event TEST_CANNOT') 229 | eventList.length = 0 230 | interpreter.send(Type.TEST_CANNOT) 231 | await new Promise(resolve => setTimeout(resolve, 0)) 232 | t.same(eventList, [ 233 | { type: Type.TEST_CANNOT }, 234 | ], 'should no match because mailbox has no current message with origin') 235 | 236 | /** 237 | * match if the event type is acceptable by child 238 | * and inside Mailbox.State.Busy state and there's a current message origin in Mailbox context 239 | */ 240 | eventList.length = 0 241 | t.ok((actorInterpreter as Interpreter).state.can(Type.TEST_CAN), 'actor can accept event TEST_CAN') 242 | interpreter.send(Type.TEST_CAN) 243 | await new Promise(resolve => setTimeout(resolve, 0)) 244 | t.same(eventList, [ 245 | { type: Type.TEST_CAN }, 246 | { type: Type.MATCH }, 247 | ], 'should match because child is in busy state & has current message origin') 248 | 249 | /** 250 | * no match if we clear the current message origin in Mailbox context 251 | */ 252 | eventList.length = 0 253 | interpreter.send(Type.CLEAR_ORIGIN) 254 | await new Promise(resolve => setTimeout(resolve, 0)) 255 | t.notOk(mailboxContext().message, 'should has no message in Mailbox context after CLEAR_ORIGIN') 256 | interpreter.send(Type.TEST_CAN) 257 | await new Promise(resolve => setTimeout(resolve, 0)) 258 | t.same(eventList, [ 259 | { type: Type.CLEAR_ORIGIN }, 260 | { type: Type.TEST_CAN }, 261 | ], 'should no match because mailbox has no current message origin') 262 | 263 | interpreter.stop() 264 | }) 265 | -------------------------------------------------------------------------------- /src/context/cond/is-child-busy-acceptable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import type { AnyEventObject, GuardMeta } from 'xstate' 21 | 22 | import * as duck from '../../duck/mod.js' 23 | 24 | import * as child from '../child/mod.js' 25 | import * as request from '../request/mod.js' 26 | 27 | import type { Context } from '../context.js' 28 | 29 | /** 30 | * 1. the child is busy 31 | */ 32 | const isStateBusy = (meta: GuardMeta) => meta.state.value === duck.State.Busy 33 | 34 | /** 35 | * 2. the new message origin is the same as the curent message which is in processing 36 | */ 37 | const isRequestAddress = (ctx: Context, meta: GuardMeta) => !!meta._event.origin && meta._event.origin === request.address(ctx) 38 | 39 | /** 40 | * 3. the new message type can be accepted by the current actor state 41 | */ 42 | const canChildAccept = (event : AnyEventObject, meta: GuardMeta, childId: string) => !!(child.snapshot(childId)(meta.state)?.can(event.type)) 43 | 44 | /** 45 | * Check condition of whether an event can be accepted by the child id (with currying) 46 | * 47 | * Huan(202204): This check is for user cases like collecting feedbacks in bot5 assistant: 48 | * when an actor is in it's Busy state, it might still need to accept new emssages 49 | * from same actor who it's working with currently, for multiple times. (accepting new messages) 50 | * 51 | * Conditions must be satisfied: 52 | * 1. the child is busy 53 | * 2. the new message origin is the same as the curent message which is in processing 54 | * 3. the new message type can be accepted by the current actor state 55 | */ 56 | export const isChildBusyAcceptable = (childId: string) => 57 | ( 58 | ctx : Context, 59 | event : AnyEventObject, 60 | meta : GuardMeta, 61 | ) => { 62 | return isStateBusy(meta) 63 | && isRequestAddress(ctx, meta) 64 | && canChildAccept(event, meta, childId) 65 | } 66 | -------------------------------------------------------------------------------- /src/context/cond/is-event-from.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { test } from 'tstest' 5 | import type { ActorRef, GuardMeta, SCXML } from 'xstate' 6 | 7 | import { isEventFrom } from './is-event-from.js' 8 | 9 | test('isEventFrom()', async t => { 10 | const SESSION_ID = 'session-id' 11 | 12 | const _EVENT = { 13 | origin: SESSION_ID, 14 | } as any as SCXML.Event 15 | 16 | const WRAPPED_ACTOR_ID = 'wrapped-id' 17 | 18 | const CHILDREN: Record> = { 19 | [WRAPPED_ACTOR_ID]: { 20 | sessionId: SESSION_ID, 21 | } as any as ActorRef, 22 | } 23 | 24 | const META = { 25 | _event: _EVENT, 26 | state: { children: CHILDREN }, 27 | } as GuardMeta 28 | 29 | t.ok(isEventFrom(WRAPPED_ACTOR_ID)({}, _EVENT, META), 'should return true if the event origin is the child session id') 30 | 31 | META._event.origin = undefined 32 | t.notOk(isEventFrom(WRAPPED_ACTOR_ID)({}, _EVENT, META), 'should return false if the event origin is undefined') 33 | 34 | t.notOk( 35 | isEventFrom(WRAPPED_ACTOR_ID)({}, _EVENT, { _event: {} } as any), 36 | 'should return false for undefined origin', 37 | ) 38 | }) 39 | -------------------------------------------------------------------------------- /src/context/cond/is-event-from.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import type { AnyEventObject, GuardMeta } from 'xstate' 21 | 22 | import type { Address } from '../../impls/address-interface.js' 23 | import type { Mailbox } from '../../interface.js' 24 | 25 | import * as child from '../child/mod.js' 26 | 27 | /** 28 | * Check condition of whether an event is sent from the session/child id (with currying) 29 | * 30 | * @param address { string | Address | Mailbox } 31 | * - string: the session id (x:0) or the child id (child-id) 32 | */ 33 | export const isEventFrom = (address: string | Address | Mailbox) => { 34 | 35 | if (typeof address !== 'string') { 36 | const id = String(address) 37 | return (_: any, __: any, meta: GuardMeta): boolean => 38 | meta._event.origin === id 39 | } 40 | 41 | return (_: any, __: any, meta: GuardMeta): boolean => 42 | !meta._event.origin ? false 43 | /** 44 | * 1. `address` match `sessionId` (origin), or 45 | */ 46 | : meta._event.origin === address ? true 47 | /** 48 | * 2. `address` is a valid `childId`, and the `origin` is equal to its `sessionId` 49 | */ 50 | : meta._event.origin === child.sessionId(address)(meta.state.children) 51 | } 52 | -------------------------------------------------------------------------------- /src/context/cond/mod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | export { isChildBusyAcceptable } from './is-child-busy-acceptable.js' 21 | export { isEventFrom } from './is-event-from.js' 22 | -------------------------------------------------------------------------------- /src/context/context.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import type { AnyEventObjectExt } from './origin/mod.js' 21 | 22 | export interface Context { 23 | /** 24 | * current message: only the received events which should sent to child, is a `message` 25 | * 26 | * current message: actor module must only process one message one time 27 | * a message will only start to be processed (send to the child) 28 | * when the child is ready for processing the next message (in its idle state) 29 | */ 30 | message?: AnyEventObjectExt 31 | /** 32 | * message queue: `queue` is for storing messages. 33 | * 34 | * a message is an event: (external events, which should be proxyed to the child) 35 | * 1. neither sent from mailbox 36 | * 2. nor from child 37 | * 38 | * TODO: Huan(202201): use yocto-queue to replace the array for better performance under high load 39 | */ 40 | queue: AnyEventObjectExt[] 41 | index: number // current message index in queue 42 | } 43 | -------------------------------------------------------------------------------- /src/context/initial-context.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from './context.js' 2 | 3 | /** 4 | * use JSON.parse() to prevent the initial context from being changed 5 | */ 6 | export function initialContext (): Context { 7 | const context: Context = { 8 | index : 0, 9 | message : undefined, 10 | queue : [], 11 | } 12 | return JSON.parse( 13 | JSON.stringify( 14 | context, 15 | ), 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/context/mod.ts: -------------------------------------------------------------------------------- 1 | export type { Context } from './context.js' 2 | export { initialContext } from './initial-context.js' 3 | 4 | export * as child from './child/mod.js' 5 | export * as cond from './cond/mod.js' 6 | export * as origin from './origin/mod.js' 7 | export * as queue from './queue/mod.js' 8 | export * as request from './request/mod.js' 9 | -------------------------------------------------------------------------------- /src/context/origin/any-event-object-meta.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import type { AnyEventObject, SCXML } from 'xstate' 21 | 22 | export const metaSymKey = Symbol('meta') 23 | 24 | /** 25 | * 26 | * Huan(202112): The Actor Model here need to be improved. 27 | * @see https://github.com/wechaty/bot5-assistant/issues/4 28 | * 29 | */ 30 | interface AnyEventObjectMeta { 31 | [metaSymKey]: { 32 | origin: SCXML.Event['origin'] 33 | } 34 | } 35 | 36 | export type AnyEventObjectExt = AnyEventObject & AnyEventObjectMeta 37 | -------------------------------------------------------------------------------- /src/context/origin/meta-origin.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import { AnyEventObjectExt, metaSymKey } from './any-event-object-meta.js' 21 | 22 | /** 23 | * Get the `origin` (session id of the xstate machine) from the event's `metaSymKey` 24 | * we use it as the `address` of the Mailbox. 25 | */ 26 | export const metaOrigin = (event?: null | AnyEventObjectExt) => (event && event[metaSymKey].origin) || undefined 27 | -------------------------------------------------------------------------------- /src/context/origin/mod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | export { metaOrigin } from './meta-origin.js' 21 | export { unwrapEvent } from './unwrap-event.js' 22 | export { wrapEvent } from './wrap-event.js' 23 | export { 24 | type AnyEventObjectExt, 25 | metaSymKey, 26 | } from './any-event-object-meta.js' 27 | -------------------------------------------------------------------------------- /src/context/origin/unwrap-event.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import type { AnyEventObject } from 'xstate' 21 | import { AnyEventObjectExt, metaSymKey } from './any-event-object-meta.js' 22 | 23 | /** 24 | * Remove the `metaSymKey` from a wrapped event 25 | */ 26 | export const unwrapEvent = (e: AnyEventObjectExt): AnyEventObject => { 27 | const wrappedEvent = { 28 | ...e, 29 | } 30 | // console.info(`unwrapEvent: ${wrappedEvent.type}@${metaOrigin(wrappedEvent)}`) 31 | 32 | delete (wrappedEvent as any)[metaSymKey] 33 | return wrappedEvent 34 | } 35 | -------------------------------------------------------------------------------- /src/context/origin/wrap-event.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import type { AnyEventObject } from 'xstate' 21 | 22 | import { metaSymKey } from './any-event-object-meta.js' 23 | 24 | /** 25 | * Wrap an event by adding `metaSymKey` to the event with value `origin` to store the session id of the xstate machine 26 | */ 27 | export const wrapEvent = (event: AnyEventObject, origin?: string) => { 28 | const wrappedEvent = ({ 29 | ...event, 30 | [metaSymKey]: { 31 | origin, 32 | }, 33 | }) 34 | // console.info(`wrapEvent: ${wrappedEvent.type}@${metaOrigin(wrappedEvent)}`) 35 | return wrappedEvent 36 | } 37 | -------------------------------------------------------------------------------- /src/context/queue/dequeue.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { test } from 'tstest' 5 | 6 | import * as origin from '../origin/mod.js' 7 | 8 | import { initialContext } from '../initial-context.js' 9 | 10 | import { dequeue } from './dequeue.js' 11 | 12 | test('dequeue()', async t => { 13 | const EVENT = { 14 | type: 'test-type', 15 | [origin.metaSymKey]: { 16 | origin: 'test-origin', 17 | }, 18 | } 19 | 20 | const CONTEXT = initialContext() 21 | CONTEXT.queue = [ EVENT ] 22 | 23 | t.same(CONTEXT.queue, [ EVENT ], 'should be one EVENT before dequeue event') 24 | const index = (dequeue.assignment as any).index(CONTEXT, undefined, { _event: {} }) 25 | t.same(CONTEXT.queue, [ EVENT ], 'should be one EVENT after dequeue event') 26 | t.equal(index, 1, 'should be at index 1 after dequeue event') 27 | }) 28 | -------------------------------------------------------------------------------- /src/context/queue/dequeue.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | /** 3 | * Wechaty Open Source Software - https://github.com/wechaty 4 | * 5 | * @copyright 2022 Huan LI (李卓桓) , and 6 | * Wechaty Contributors . 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * 20 | */ 21 | import { actions } from 'xstate' 22 | 23 | import type { Context } from '../context.js' 24 | 25 | /** 26 | * dequeue ctx.queue by updating the index by increasing 1 (current message pointer move forward) 27 | */ 28 | export const dequeue = actions.assign({ 29 | // message: ctx => ctx.queue.shift()!, 30 | index: ctx => ctx.index + 1, 31 | }) 32 | -------------------------------------------------------------------------------- /src/context/queue/empty-queue.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { test } from 'tstest' 5 | 6 | import { emptyQueue } from './empty-queue.js' 7 | 8 | test('emptyQueue()', async t => { 9 | const queue = (emptyQueue.assignment as any).queue({} as any) 10 | t.same(queue, [], 'should be empty queue') 11 | const index = (emptyQueue.assignment as any).index({} as any) 12 | t.equal(index, 0, 'should be index 0') 13 | }) 14 | -------------------------------------------------------------------------------- /src/context/queue/empty-queue.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | /** 3 | * Wechaty Open Source Software - https://github.com/wechaty 4 | * 5 | * @copyright 2022 Huan LI (李卓桓) , and 6 | * Wechaty Contributors . 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * 20 | */ 21 | import { actions } from 'xstate' 22 | 23 | import type { Context } from '../context.js' 24 | 25 | /** 26 | * Reset the queue and index 27 | */ 28 | export const emptyQueue = actions.assign({ 29 | index: () => 0, 30 | queue: () => [], 31 | }) 32 | -------------------------------------------------------------------------------- /src/context/queue/enqueue.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { test } from 'tstest' 5 | 6 | import * as origin from '../origin/mod.js' 7 | import { initialContext } from '../initial-context.js' 8 | 9 | import { enqueue } from './enqueue.js' 10 | 11 | test('enqueue', async t => { 12 | const CONTEXT = initialContext() 13 | const EVENT = { 14 | type: 'test-type', 15 | [origin.metaSymKey]: { 16 | origin: 'test-origin', 17 | }, 18 | } 19 | 20 | t.equal(enqueue.type, 'xstate.assign', 'should be in `assign` type') 21 | 22 | const queue = (enqueue.assignment as any).queue(CONTEXT, EVENT, { _event: { origin: 'test-origin' } }) 23 | t.same(queue, [ EVENT ], 'should enqueue event to context.queue') 24 | }) 25 | -------------------------------------------------------------------------------- /src/context/queue/enqueue.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | /** 3 | * Wechaty Open Source Software - https://github.com/wechaty 4 | * 5 | * @copyright 2022 Huan LI (李卓桓) , and 6 | * Wechaty Contributors . 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * 20 | */ 21 | import { actions, AnyEventObject } from 'xstate' 22 | 23 | import type { Context } from '../context.js' 24 | import * as origin from '../origin/mod.js' 25 | 26 | /** 27 | * wrap an event as a message and enqueue it to ctx.queue as a new message 28 | */ 29 | export const enqueue = actions.assign({ 30 | queue: (ctx, e, { _event }) => [ 31 | ...ctx.queue, 32 | origin.wrapEvent(e, _event.origin), 33 | ], 34 | }) 35 | -------------------------------------------------------------------------------- /src/context/queue/message.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | /** 3 | * Wechaty Open Source Software - https://github.com/wechaty 4 | * 5 | * @copyright 2022 Huan LI (李卓桓) , and 6 | * Wechaty Contributors . 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * 20 | */ 21 | import type { Context } from '../context.js' 22 | 23 | export const message = (ctx: Context) => ctx.queue[ctx.index] 24 | -------------------------------------------------------------------------------- /src/context/queue/mod.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | /** 3 | * Wechaty Open Source Software - https://github.com/wechaty 4 | * 5 | * @copyright 2022 Huan LI (李卓桓) , and 6 | * Wechaty Contributors . 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * 20 | */ 21 | export { dequeue } from './dequeue.js' 22 | export { emptyQueue } from './empty-queue.js' 23 | export { enqueue } from './enqueue.js' 24 | export { size } from './size.js' 25 | export { message } from './message.js' 26 | export { newMessage } from './new-message.js' 27 | -------------------------------------------------------------------------------- /src/context/queue/new-message.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | /** 3 | * Wechaty Open Source Software - https://github.com/wechaty 4 | * 5 | * @copyright 2022 Huan LI (李卓桓) , and 6 | * Wechaty Contributors . 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * 20 | */ 21 | import { actions, AnyEventObject } from 'xstate' 22 | 23 | import * as duck from '../../duck/mod.js' 24 | import * as is from '../../is/mod.js' 25 | import { wrappedId, mailboxId } from '../../mailbox-id.js' 26 | 27 | import type { Context } from '../context.js' 28 | import * as cond from '../cond/mod.js' 29 | import * as assign from './mod.js' 30 | 31 | import { size } from './size.js' 32 | 33 | /** 34 | * 1. Skip Mailbox internal event 35 | * 2. skip Child event 36 | * 3. send(drop) letter to DLQ if capacity overflow 37 | * 4. emit NEW_MESSAGE after enqueue the message to the queue 38 | */ 39 | export const newMessage = (actorId: string) => (capacity = Infinity) => actions.choose([ 40 | { 41 | // 1.1. Ignore all Mailbox.Types.* because they are internal messages 42 | cond: (_, e) => is.isMailboxType(e.type), 43 | actions: actions.log((_, e) => `newMessage [${e.type}] ignored system message`, mailboxId(actorId)), 44 | }, 45 | { 46 | // 1.2. Ignore Child events (origin from child machine) because they are sent from the child machine 47 | cond: cond.isEventFrom(wrappedId(actorId)), 48 | actions: actions.log((_, e) => `newMessage [${e.type}] ignored internal message`, mailboxId(actorId)), 49 | }, 50 | { 51 | // 1.3. Ignore events without origin because they can not be replied 52 | cond: (_, __, { _event }) => !_event.origin, 53 | actions: [ 54 | actions.log((_, e) => `newMessage [${e.type}] ignored non-origin message (un-repliable)`, mailboxId(actorId)), 55 | actions.send((_, e) => duck.Event.DEAD_LETTER(e, 'message has no origin (un-repliable)')), 56 | ], 57 | }, 58 | { 59 | /** 60 | * 2. Bounded mailbox: out of capicity, send them to Dead Letter Queue (DLQ) 61 | */ 62 | cond: ctx => size(ctx) > capacity, 63 | actions: [ 64 | actions.log((ctx, e, { _event }) => `newMessage(${capacity}) dead letter [${e.type}]@${_event.origin || ''} because out of capacity: queueSize(${size(ctx)}) > capacity(${capacity})`, mailboxId(actorId)), 65 | actions.send((ctx, e) => duck.Event.DEAD_LETTER(e, `queueSize(${size(ctx)} out of capacity(${capacity})`)), 66 | ], 67 | }, 68 | /** 69 | * 70 | * Child is **Busy** but **can** accept this message type 71 | * 72 | */ 73 | { 74 | /** 75 | * 4. Forward to child when the child can accept this new arrived event even it's busy 76 | * for prevent deaadlock when child actor want to receive events at BUSY state. 77 | */ 78 | cond: cond.isChildBusyAcceptable(wrappedId(actorId)), 79 | actions: [ 80 | actions.log((_, e, { _event }) => `newMessage [${e.type}]@${_event.origin} child is busy but can accept [${e.type}] from the same request origin (address) ${_event.origin}`, mailboxId(actorId)), 81 | /** 82 | * keep the original of event by forwarding(`forwardTo`, instead of `send`) it 83 | * 84 | * Huan(202204): does the above `forwardTo` necessary? 85 | * consider to use `send` to replace `forwardTo` 86 | * because a message will be acceptable only if it is sending from the same origin as the current processing message 87 | */ 88 | actions.forwardTo(wrappedId(actorId)), 89 | ], 90 | }, 91 | { 92 | /** 93 | * 3. Add incoming message to queue by wrapping the `_event.origin` meta data 94 | */ 95 | actions: [ 96 | actions.log((_, e, { _event }) => `newMessage [${e.type}]@${_event.origin} external message accepted`, mailboxId(actorId)), 97 | assign.enqueue, // <- wrapping `_event.origin` inside 98 | actions.send((_, e) => duck.Event.NEW_MESSAGE(e.type)), 99 | ], 100 | }, 101 | 102 | ]) 103 | -------------------------------------------------------------------------------- /src/context/queue/size.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { initialContext } from '../initial-context.js' 6 | 7 | import { size } from './size.js' 8 | 9 | test('queueSize()', async t => { 10 | const EMPTY_CONTEXT = initialContext() 11 | 12 | const NONEMPTY_CONTEXT = initialContext() 13 | NONEMPTY_CONTEXT.queue = [ {} as any ] 14 | 15 | t.equal(size(EMPTY_CONTEXT), 0, 'should be empty when queue is empty') 16 | t.equal(size(NONEMPTY_CONTEXT), 1, 'should be not empty when queue has one message') 17 | 18 | NONEMPTY_CONTEXT.index = 1 19 | t.equal(size(NONEMPTY_CONTEXT), 0, 'should be empty when index set to 1') 20 | }) 21 | -------------------------------------------------------------------------------- /src/context/queue/size.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | /** 3 | * Wechaty Open Source Software - https://github.com/wechaty 4 | * 5 | * @copyright 2022 Huan LI (李卓桓) , and 6 | * Wechaty Contributors . 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * 20 | */ 21 | import type { Context } from '../context.js' 22 | 23 | export const size = (ctx: Context) => ctx.queue.length - ctx.index 24 | -------------------------------------------------------------------------------- /src/context/request/address.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { address } from './address.js' 6 | 7 | test('tbw', async t => { 8 | t.ok(address, 'tbw') 9 | }) 10 | -------------------------------------------------------------------------------- /src/context/request/address.ts: -------------------------------------------------------------------------------- 1 | import * as origin from '../origin/mod.js' 2 | import type { Context } from '../context.js' 3 | 4 | import { message } from './message.js' 5 | 6 | /** 7 | * The origin (machine session, mailbox address) of the current message(event) 8 | * 1. `origin` is the session id of the child machine 9 | * 2. we use it as the `address` of the Mailbox. 10 | */ 11 | export const address = (ctx: Context) => origin.metaOrigin(message(ctx)) 12 | -------------------------------------------------------------------------------- /src/context/request/message.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { message } from './message.js' 6 | 7 | test('tbw', async t => { 8 | t.ok(message, 'tbw') 9 | }) 10 | -------------------------------------------------------------------------------- /src/context/request/message.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from '../context.js' 2 | 3 | /** 4 | * The current message(event) that is being processed by the Mailbox system 5 | */ 6 | export const message = (ctx: Context) => ctx.message 7 | -------------------------------------------------------------------------------- /src/context/request/mod.ts: -------------------------------------------------------------------------------- 1 | export { message } from './message.js' 2 | export { address } from './address.js' 3 | -------------------------------------------------------------------------------- /src/duck/event-fancy-enum.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-redeclare */ 2 | import * as events from './events.js' 3 | 4 | export type Event = { 5 | [K in keyof typeof events]: ReturnType 6 | } 7 | export const Event = events 8 | -------------------------------------------------------------------------------- /src/duck/events.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | /* eslint-disable sort-keys */ 21 | import { createAction } from 'typesafe-actions' 22 | import type { AnyEventObject, EventObject } from 'xstate' 23 | 24 | import type * as context from '../context/mod.js' 25 | 26 | import * as types from './types.js' 27 | 28 | /** 29 | * events of: child 30 | * 31 | * IDLE is the most important event for Mailbox actor: 32 | * it must be send whenever the child machine is idle. 33 | * so that the Mailbox can be able to send messages to the child machine 34 | */ 35 | export const ACTOR_IDLE = createAction(types.ACTOR_IDLE)() 36 | 37 | const payloadActorReply = (message: EventObject) => ({ message }) 38 | export const ACTOR_REPLY = createAction(types.ACTOR_REPLY, payloadActorReply)() 39 | 40 | /** 41 | * events of: queue 42 | */ 43 | const payloadNewMessage = (type: string) => ({ type }) 44 | export const NEW_MESSAGE = createAction(types.NEW_MESSAGE, payloadNewMessage)() 45 | 46 | const payloadDequeue = (message: context.origin.AnyEventObjectExt) => ({ message }) 47 | export const DEQUEUE = createAction(types.DEQUEUE, payloadDequeue)() 48 | 49 | /** 50 | * events for : debuging 51 | */ 52 | const payloadDeadLetter = (message: AnyEventObject, data?: string) => ({ message, data }) 53 | export const DEAD_LETTER = createAction(types.DEAD_LETTER, payloadDeadLetter)() 54 | -------------------------------------------------------------------------------- /src/duck/mod.ts: -------------------------------------------------------------------------------- 1 | export * from './type-fancy-enum.js' 2 | export * from './event-fancy-enum.js' 3 | export * from './state-fancy-enum.js' 4 | -------------------------------------------------------------------------------- /src/duck/state-fancy-enum.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-redeclare */ 2 | import * as states from './states.js' 3 | 4 | export type State = { 5 | [K in keyof typeof states]: typeof states[K] 6 | } 7 | export const State = states 8 | -------------------------------------------------------------------------------- /src/duck/states.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | 21 | /** 22 | * Idle Time – Definition, Causes, And How To Reduce It 23 | * @see https://limblecmms.com/blog/idle-time/ 24 | * 25 | * Note: idle & busy are only for Async mode. 26 | * non-async mode should use listening/standby (see below) 27 | */ 28 | export const Idle = 'mailbox/Idle' 29 | export const Busy = 'mailbox/Busy' 30 | -------------------------------------------------------------------------------- /src/duck/type-fancy-enum.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-redeclare */ 2 | import * as types from './types.js' 3 | 4 | export type Type = { 5 | [K in keyof typeof types]: typeof types[K] 6 | } 7 | export const Type = types 8 | -------------------------------------------------------------------------------- /src/duck/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | 21 | /** 22 | * sub state types of: child 23 | */ 24 | export const ACTOR_IDLE = 'mailbox/ACTOR_IDLE' 25 | export const ACTOR_REPLY = 'mailbox/ACTOR_REPLY' 26 | 27 | /** 28 | * sub state types of: queue 29 | */ 30 | export const NEW_MESSAGE = 'mailbox/NEW_MESSAGE' 31 | export const DEQUEUE = 'mailbox/DEQUEUE' 32 | 33 | /** 34 | * types of: debug 35 | */ 36 | export const DEAD_LETTER = 'mailbox/DEAD_LETTER' 37 | -------------------------------------------------------------------------------- /src/duckula/duckula.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionCreator, 3 | ActionCreatorTypeMetadata, 4 | } from 'typesafe-actions' 5 | import type { AnyStateMachine } from 'xstate' 6 | 7 | export type Type = { 8 | [key in K]: V 9 | } 10 | 11 | export type State = { 12 | [key in K]: V 13 | } 14 | 15 | export type Event = { 16 | [key in K]: ActionCreator & ActionCreatorTypeMetadata 17 | } 18 | 19 | export interface Duckula < 20 | TID extends string = string, 21 | 22 | TEvent extends Event = Event, 23 | TState extends State = State, 24 | TType extends Type = Type, 25 | 26 | TContext extends {} = {}, 27 | TMachine extends AnyStateMachine = AnyStateMachine, 28 | > { 29 | id: TID 30 | Type: TType 31 | Event: TEvent 32 | State: TState 33 | machine: TMachine 34 | initialContext: () => TContext 35 | } 36 | -------------------------------------------------------------------------------- /src/duckula/duckularize-options.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | /* eslint-disable no-redeclare */ 4 | 5 | import { test, AssertEqual } from 'tstest' 6 | 7 | import * as duck from '../duck/mod.js' 8 | 9 | import type { DuckularizeOptions } from './duckularize-options.js' 10 | 11 | const FIXTURE = { 12 | 13 | ID: 'duckula-id', 14 | 15 | Event: { 16 | ACTOR_IDLE: duck.Event.ACTOR_IDLE, 17 | ACTOR_REPLY: duck.Event.ACTOR_REPLY, 18 | }, 19 | 20 | State: { 21 | Idle: duck.State.Idle, 22 | Busy: duck.State.Busy, 23 | }, 24 | 25 | Type: { 26 | ACTOR_IDLE: duck.Type.ACTOR_IDLE, 27 | ACTOR_REPLY: duck.Type.ACTOR_REPLY, 28 | }, 29 | 30 | initialContext: () => ({ n: 42 }), 31 | } as const 32 | 33 | test('DuckularizeOptions typing inference smoke testing', async t => { 34 | 35 | const options = { 36 | id: FIXTURE.ID, 37 | events: FIXTURE.Event, 38 | states: FIXTURE.State, 39 | initialContext: FIXTURE.initialContext(), 40 | } as const 41 | 42 | type Actual = typeof options extends DuckularizeOptions 43 | ? TEvent 44 | : never 45 | 46 | type Expected = keyof typeof FIXTURE.Event 47 | 48 | const typingTest: AssertEqual = true 49 | t.ok(typingTest, 'should match typing') 50 | }) 51 | -------------------------------------------------------------------------------- /src/duckula/duckularize-options.ts: -------------------------------------------------------------------------------- 1 | import type * as duckula from './duckula.js' 2 | 3 | export interface DuckularizeOptionsArray < 4 | TID extends string, 5 | 6 | TType extends string, 7 | TEventKey extends string, 8 | TEvent extends duckula.Event, 9 | 10 | TStateKey extends string, 11 | TStateVal extends string, 12 | TState extends duckula.State, 13 | 14 | TContext extends {}, 15 | > { 16 | id: TID 17 | events: [ 18 | TEvent, 19 | TEventKey[], 20 | ] 21 | states: [ 22 | TState, 23 | TStateKey[], 24 | ] 25 | initialContext: TContext 26 | } 27 | 28 | export interface DuckularizeOptionsObject < 29 | TID extends string, 30 | 31 | TType extends string, 32 | TEventKey extends string, 33 | TEvent extends duckula.Event, 34 | 35 | TStateKey extends string, 36 | TStateVal extends string, 37 | TState extends duckula.State, 38 | 39 | TContext extends {}, 40 | > { 41 | id: TID 42 | events: TEvent 43 | states: TState 44 | initialContext: TContext 45 | } 46 | 47 | export type DuckularizeOptions< 48 | TID extends string, 49 | 50 | TType extends string, 51 | TEventKey extends string, 52 | TEvent extends duckula.Event, 53 | 54 | TStateKey extends string, 55 | TStateVal extends string, 56 | TState extends duckula.State, 57 | 58 | TContext extends {}, 59 | > = 60 | | DuckularizeOptionsObject 61 | | DuckularizeOptionsArray 62 | -------------------------------------------------------------------------------- /src/duckula/duckularize.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | /* eslint-disable no-redeclare */ 4 | 5 | import { test, AssertEqual } from 'tstest' 6 | 7 | import * as duck from '../duck/mod.js' 8 | 9 | import { duckularize } from './duckularize.js' 10 | 11 | const FIXTURE = { 12 | 13 | id: 'duckula-id', 14 | 15 | Event: { 16 | ACTOR_IDLE: duck.Event.ACTOR_IDLE, 17 | ACTOR_REPLY: duck.Event.ACTOR_REPLY, 18 | }, 19 | 20 | State: { 21 | Idle: duck.State.Idle, 22 | Busy: duck.State.Busy, 23 | }, 24 | 25 | Type: { 26 | ACTOR_IDLE: duck.Type.ACTOR_IDLE, 27 | ACTOR_REPLY: duck.Type.ACTOR_REPLY, 28 | }, 29 | 30 | initialContext: () => ({ n: 42 }), 31 | } as const 32 | 33 | test('duckularize() array param values', async t => { 34 | 35 | const duckula = duckularize({ 36 | id: FIXTURE.id, 37 | events: [ duck.Event, [ 38 | 'ACTOR_IDLE', 39 | 'ACTOR_REPLY', 40 | ] ], 41 | states: [ 42 | duck.State, [ 43 | 'Idle', 44 | 'Busy', 45 | ] ], 46 | initialContext: FIXTURE.initialContext(), 47 | }) 48 | 49 | t.same( 50 | JSON.parse(JSON.stringify(duckula)), 51 | JSON.parse(JSON.stringify(FIXTURE)), 52 | 'should get the expected dockula', 53 | ) 54 | }) 55 | 56 | test('duckularize() array param typings', async t => { 57 | 58 | const duckula = duckularize({ 59 | id: FIXTURE.id, 60 | events: [ duck.Event, [ 61 | 'ACTOR_REPLY', 62 | 'ACTOR_IDLE', 63 | ] ], 64 | states: [ 65 | duck.State, [ 66 | 'Idle', 67 | 'Busy', 68 | ] ], 69 | initialContext: FIXTURE.initialContext(), 70 | }) 71 | 72 | type Duckula = typeof duckula 73 | type Expected = typeof FIXTURE 74 | 75 | const typingTest: AssertEqual = true 76 | t.ok(typingTest, 'should match typing') 77 | }) 78 | 79 | test('duckularize() object param values', async t => { 80 | 81 | const duckula = duckularize({ 82 | id: FIXTURE.id, 83 | events: FIXTURE.Event, 84 | states: FIXTURE.State, 85 | initialContext: FIXTURE.initialContext(), 86 | }) 87 | 88 | const EXPECTED_DUCKULA = { 89 | ...FIXTURE, 90 | Event: FIXTURE.Event, 91 | State: FIXTURE.State, 92 | Type: FIXTURE.Type, 93 | } 94 | 95 | t.same( 96 | JSON.parse(JSON.stringify(duckula)), 97 | JSON.parse(JSON.stringify(EXPECTED_DUCKULA)), 98 | 'should get the expected dockula', 99 | ) 100 | }) 101 | 102 | test('duckularize() object param typings', async t => { 103 | 104 | const duckula = duckularize({ 105 | id: FIXTURE.id, 106 | events: FIXTURE.Event, 107 | states: FIXTURE.State, 108 | initialContext: FIXTURE.initialContext(), 109 | }) 110 | 111 | const EXPECTED_DUCKULA = { 112 | ...FIXTURE, 113 | Event: FIXTURE.Event, 114 | State: FIXTURE.State, 115 | Type: FIXTURE.Type, 116 | } 117 | 118 | type DuckulaEvent = typeof duckula.Event 119 | type DuckulaState = typeof duckula.State 120 | type DuckulaType = typeof duckula.Type 121 | type DuckulaContext = ReturnType 122 | 123 | type ExpectedEvent = typeof EXPECTED_DUCKULA.Event 124 | type ExpectedState = typeof EXPECTED_DUCKULA.State 125 | type ExpectedType = typeof EXPECTED_DUCKULA.Type 126 | type ExpectedContext = ReturnType 127 | 128 | const typingTestEvent: AssertEqual = true 129 | const typingTestState: AssertEqual = true 130 | const typingTestType: AssertEqual = true 131 | const typingTestContext: AssertEqual = true 132 | 133 | t.ok(typingTestEvent, 'should match typing for Event') 134 | t.ok(typingTestState, 'should match typing for State') 135 | t.ok(typingTestType, 'should match typing for Type') 136 | t.ok(typingTestContext, 'should match typing for Context') 137 | }) 138 | -------------------------------------------------------------------------------- /src/duckula/duckularize.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | /* eslint-disable no-redeclare */ 3 | import { 4 | ActionCreator, 5 | ActionCreatorTypeMetadata, 6 | getType, 7 | } from 'typesafe-actions' 8 | import type { Optional } from 'utility-types' 9 | 10 | import type { 11 | DuckularizeOptions, 12 | DuckularizeOptionsArray, 13 | DuckularizeOptionsObject, 14 | } from './duckularize-options.js' 15 | import { selector } from './selector.js' 16 | import type * as D from './duckula.js' 17 | 18 | /** 19 | * duckularize() is the `Duckula` specification builder 20 | * 21 | * `Dukula` is a schema standard for Mailbox Actor Systems. 22 | * It is a specification for the Actor System's mailbox. 23 | * 24 | * @returns {Duckula} A object that satisfy the duckula specification: { id, Type, Event, State, initialContext, machine } 25 | * 26 | * @see https://github.com/huan/mailbox/issues/1 27 | */ 28 | export function duckularize < 29 | TID extends string, 30 | 31 | TType extends string, 32 | TEventKey extends string, 33 | TEvent extends D.Event, 34 | 35 | TStateKey extends string, 36 | TStateVal extends string, 37 | TState extends D.State, 38 | 39 | TContext extends {}, 40 | > ( 41 | options: DuckularizeOptionsArray, 42 | ): Optional< 43 | D.Duckula< 44 | TID, 45 | { [K in TEventKey]: TEvent[K] }, 46 | { [K in TStateKey]: TState[K] }, 47 | { [K in TEventKey]: TEvent[K] extends ActionCreator & ActionCreatorTypeMetadata ? TType : never }, 48 | TContext 49 | >, 50 | 'machine' 51 | > 52 | 53 | /** 54 | * duckularize() is the `Duckula` specification builder 55 | * 56 | * `Dukula` is a schema standard for Mailbox Actor Systems. 57 | * It is a specification for the Actor System's mailbox. 58 | * 59 | * @returns {Duckula} A object that satisfy the duckula specification: { id, Type, Event, State, initialContext, machine } 60 | * 61 | * @see https://github.com/huan/mailbox/issues/1 62 | */ 63 | export function duckularize < 64 | TID extends string, 65 | 66 | TType extends string, 67 | TEventKey extends string, 68 | TEvent extends D.Event, 69 | 70 | TStateKey extends string, 71 | TStateVal extends string, 72 | TState extends D.State, 73 | 74 | TContext extends {}, 75 | > ( 76 | options: DuckularizeOptionsObject, 77 | ): Optional< 78 | D.Duckula< 79 | TID, 80 | TEvent, 81 | TState, 82 | { [K in keyof TEvent]: TEvent[K] extends ActionCreator & ActionCreatorTypeMetadata ? TType : never }, 83 | TContext 84 | >, 85 | 'machine' 86 | > 87 | 88 | /** 89 | * Huan(202204): we have to use override function definition for different `options` 90 | * 91 | * The `options` must be seperate for the `Array` and `Object` type 92 | * or the typing inference will be incorrect. 93 | * 94 | * TODO: merge the options as an union type to reduce the complexity 95 | */ 96 | export function duckularize < 97 | TID extends string, 98 | 99 | TType extends string, 100 | TEventKey extends string, 101 | TEvent extends D.Event, 102 | 103 | TStateKey extends string, 104 | TStateVal extends string, 105 | TState extends D.State, 106 | 107 | TContext extends {}, 108 | > ( 109 | options: DuckularizeOptions, 110 | ): Optional< 111 | D.Duckula< 112 | TID, 113 | { [K in TEventKey]: TEvent[K] }, 114 | { [K in TStateKey]: TState[K] }, 115 | { [K in TEventKey]: TEvent[K] extends ActionCreator & ActionCreatorTypeMetadata ? TType : never }, 116 | TContext 117 | >, 118 | 'machine' 119 | > { 120 | /** 121 | * Huan(202204) make TypeScript overload happy for `selector()` 122 | * TODO: how to fix it? (make it clean by removing the "isArray ? : " condition) 123 | */ 124 | const State = Array.isArray(options.states) ? selector(options.states) : selector(options.states) 125 | const Event = Array.isArray(options.events) ? selector(options.events) : selector(options.events) 126 | 127 | /** 128 | * Type 129 | */ 130 | type Type = { [K in keyof typeof Event]: typeof Event[K] extends ActionCreator & ActionCreatorTypeMetadata ? TType : never } 131 | const Type = Object.keys(Event).reduce( 132 | (acc, cur) => ({ 133 | ...acc, 134 | [cur]: getType(Event[cur as keyof typeof Event]), 135 | }), 136 | {}, 137 | ) as Type 138 | 139 | /** 140 | * Huan(202204): do we need JSON parse/stringify 141 | * to make sure the initial context is always unmutable? 142 | */ 143 | const initialContext: () => typeof options.initialContext 144 | = () => JSON.parse( 145 | JSON.stringify( 146 | options.initialContext, 147 | ), 148 | ) 149 | 150 | const duckula = { 151 | id: options.id, 152 | Event, 153 | State, 154 | Type, 155 | initialContext, 156 | } 157 | 158 | return duckula 159 | } 160 | -------------------------------------------------------------------------------- /src/duckula/mod.ts: -------------------------------------------------------------------------------- 1 | export { duckularize } from './duckularize.js' 2 | export type { Duckula } from './duckula.js' 3 | -------------------------------------------------------------------------------- /src/duckula/selector.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | /* eslint-disable no-redeclare */ 4 | 5 | import { test, AssertEqual } from 'tstest' 6 | 7 | import * as duck from '../duck/mod.js' 8 | 9 | import { selector } from './selector.js' 10 | 11 | test('selector() smoke testing', async t => { 12 | 13 | const EXPECTED_EVENT = { 14 | ACTOR_IDLE: duck.Event.ACTOR_IDLE, 15 | ACTOR_REPLY: duck.Event.ACTOR_REPLY, 16 | } 17 | 18 | const Event = selector([ 19 | duck.Event, 20 | [ 21 | 'ACTOR_IDLE', 22 | 'ACTOR_REPLY', 23 | ], 24 | ]) 25 | 26 | t.same(Event, EXPECTED_EVENT, 'should get the expected Event') 27 | }) 28 | 29 | test('selector() array typing smoke testing', async t => { 30 | 31 | const EXPECTED_EVENT = { 32 | ACTOR_IDLE: duck.Event.ACTOR_IDLE, 33 | ACTOR_REPLY: duck.Event.ACTOR_REPLY, 34 | } 35 | 36 | const Event = selector([ 37 | duck.Event, 38 | [ 39 | 'ACTOR_IDLE', 40 | 'ACTOR_REPLY', 41 | ], 42 | ]) 43 | 44 | type Event = typeof Event 45 | type Expected = typeof EXPECTED_EVENT 46 | 47 | const typingTest: AssertEqual = true 48 | t.ok(typingTest, 'should match typing') 49 | }) 50 | 51 | test('selector() object typing smoke testing', async t => { 52 | 53 | const EXPECTED_EVENT = { 54 | ACTOR_IDLE: duck.Event.ACTOR_IDLE, 55 | ACTOR_REPLY: duck.Event.ACTOR_REPLY, 56 | } 57 | 58 | const Event = selector(EXPECTED_EVENT) 59 | 60 | type Event = typeof Event 61 | type Expected = typeof EXPECTED_EVENT 62 | 63 | const typingTest: AssertEqual = true 64 | t.ok(typingTest, 'should match typing') 65 | }) 66 | 67 | test('selector() with union arg', async t => { 68 | 69 | const EXPECTED_EVENT_OBJECT = { 70 | IDLE: 'IDLE', 71 | } as const 72 | 73 | const ARG_ARRAY = [ EXPECTED_EVENT_OBJECT, [ 74 | 'IDLE', 75 | ] ] as const 76 | 77 | const ARG = Math.random() > 0.5 78 | ? ARG_ARRAY 79 | : EXPECTED_EVENT_OBJECT 80 | 81 | const Event = ARG instanceof Array ? selector(ARG) : selector(ARG) 82 | 83 | type Event = typeof Event 84 | type Expected = typeof EXPECTED_EVENT_OBJECT 85 | 86 | const typingTest: AssertEqual = true 87 | t.ok(typingTest, 'should match typing') 88 | }) 89 | -------------------------------------------------------------------------------- /src/duckula/selector.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-redeclare */ 2 | 3 | /** 4 | * Huan(202204): we have to use override function definition for different `source` 5 | * 6 | * The `source` must be seperate for the `Array` and `Object` type 7 | * or the typing inference will be incorrect. 8 | * 9 | * TODO: merge the source as an union type to reduce the complexity 10 | */ 11 | export function selector < 12 | TKey extends string, 13 | TVal extends any, 14 | TMap extends { [key in TKey]: TVal }, 15 | > (source: TMap): TMap 16 | 17 | export function selector < 18 | TKey extends string, 19 | TVal extends any, 20 | TMap extends { [key in TKey]: TVal }, 21 | TSelectedKey extends TKey, 22 | > ( 23 | source: readonly [ TMap, readonly TSelectedKey[] ] 24 | ): { 25 | [K in TSelectedKey]: TMap[K] 26 | } 27 | 28 | /** 29 | * Pick the selected key from the source 30 | * 31 | * @param source 32 | * @returns 33 | */ 34 | export function selector < 35 | TKey extends string, 36 | TVal extends any, 37 | TMap extends { [key in TKey]: TVal }, 38 | TSelectedKey extends TKey, 39 | > ( 40 | source: TMap | [ TMap, TSelectedKey[] ], 41 | ) { 42 | 43 | if (!Array.isArray(source)) { 44 | return source 45 | } 46 | 47 | const [ map, keys ] = source 48 | 49 | type SelectedMap = { [K in TSelectedKey]: TMap[K] } 50 | const SelectedMap = keys.reduce( 51 | (acc, cur) => ({ 52 | ...acc, 53 | [cur]: map[cur], 54 | }), 55 | {}, 56 | ) as SelectedMap 57 | 58 | return SelectedMap 59 | } 60 | -------------------------------------------------------------------------------- /src/from.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import type { EventObject, StateMachine } from 'xstate' 21 | 22 | import { type Mailbox, type Options, MailboxImpl } from './impls/mod.js' 23 | 24 | import { wrap } from './wrap.js' 25 | 26 | /** 27 | * Create a Mailbox from the target machine 28 | * 29 | * @param machine the target machine that conform to the Mailbox Actor Message Queue API 30 | * @param options the options for the mailbox 31 | * 32 | * @returns Mailbox instance 33 | */ 34 | function from< 35 | TContext extends any, 36 | TEvent extends EventObject, 37 | > ( 38 | machine: StateMachine< 39 | TContext, 40 | any, 41 | TEvent 42 | >, 43 | options?: Options, 44 | ): Mailbox { 45 | const wrappedMachine = wrap(machine, options) 46 | return new MailboxImpl(wrappedMachine, options) 47 | } 48 | 49 | export { 50 | from, 51 | } 52 | -------------------------------------------------------------------------------- /src/impls/address-implementation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import { 21 | EventObject, 22 | actions, 23 | AnyEventObject, 24 | Event, 25 | SendExpr, 26 | SendActionOptions, 27 | SendAction, 28 | GuardMeta, 29 | } from 'xstate' 30 | 31 | import * as is from '../is/mod.js' 32 | 33 | import type { Address } from './address-interface.js' 34 | import type { Mailbox } from '../interface.js' 35 | 36 | export class AddressImpl implements Address { 37 | 38 | /** 39 | * @param { string | Mailbox | Address } target address 40 | */ 41 | static from (target: string | Address | Mailbox): Address { 42 | 43 | if (typeof target === 'string') { 44 | return new AddressImpl(target) 45 | 46 | } else if (is.isAddress(target)) { 47 | return target 48 | 49 | } else if (is.isMailbox(target)) { 50 | return target.address 51 | 52 | } else { 53 | throw new Error('unknown target type: ' + target) 54 | } 55 | } 56 | 57 | protected constructor ( 58 | public id: string, 59 | ) { 60 | } 61 | 62 | toString () { 63 | return this.id 64 | } 65 | 66 | /** 67 | * The same API with XState `actions.send` method, but only for the current binding address. 68 | */ 69 | send ( 70 | event : Event | SendExpr, 71 | options? : SendActionOptions, 72 | ): SendAction { 73 | /** 74 | * Huan(202201): Issue #11 - Race condition: Mailbox think the target machine is busy when it's not 75 | * @link https://github.com/wechaty/bot5-assistant/issues/11 76 | * 77 | * add a `delay:0` when sending events to put the send action to the v8 event loop next tick 78 | * 79 | * NOTE: to wait the `delay:0` event to be executed, we need to use `setTimeout(r, 0)` instead of `setImmediate`. 80 | * the reason is unclear, but it works. 81 | * Huan(202204): maybe related to the micro/macro tasks event loop mechanism. 82 | */ 83 | return actions.send(event, { 84 | delay: 0, 85 | ...options, 86 | to: this.id, 87 | }) 88 | } 89 | 90 | /** 91 | * Return true if the `_event.origin` is not the same and the Address 92 | */ 93 | condNotSame () { 94 | return ( 95 | _context: TContext, 96 | _event: TEvent, 97 | meta: GuardMeta, 98 | ) => meta._event.origin !== this.id 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /src/impls/address-interface.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /** 3 | * Mailbox - https://github.com/huan/mailbox 4 | * author: Huan LI (李卓桓) 5 | * Dec 2021 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { test } from 'tstest' 20 | 21 | import type { Address } from './address-interface.js' 22 | 23 | test('toString()', async t => { 24 | const ID = 'mocked_id' 25 | const address: Address = { 26 | toString: () => ID, 27 | } as any 28 | 29 | t.equal(address.toString(), ID, 'should return ID') 30 | t.equal(address + '', ID, 'should be equal to ID with + operator') 31 | t.equal(String(address), ID, 'should be equal to ID with String()') 32 | 33 | function tt (s: string) { return s + 't' } 34 | tt(address.toString()) 35 | }) 36 | -------------------------------------------------------------------------------- /src/impls/address-interface.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import type { 21 | EventObject, 22 | AnyEventObject, 23 | Event, 24 | SendExpr, 25 | SendActionOptions, 26 | SendAction, 27 | GuardMeta, 28 | } from 'xstate' 29 | 30 | /** 31 | * Mailbox Address Interface 32 | * 33 | * @interface Address 34 | * 35 | * All methods in this interface should be as the same interface of the `actions` in `xstate` 36 | * so that they will be compatible with `xstate` 37 | */ 38 | export interface Address { 39 | send ( 40 | event: Event | SendExpr, 41 | options?: SendActionOptions 42 | ): SendAction 43 | 44 | condNotSame: () => ( 45 | _context: TContext, 46 | _event: TEvent, 47 | meta: GuardMeta, 48 | ) => boolean 49 | } 50 | -------------------------------------------------------------------------------- /src/impls/get-actor-machine.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /** 3 | * Mailbox - https://github.com/huan/mailbox 4 | * author: Huan LI (李卓桓) 5 | * Dec 2021 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { test } from 'tstest' 20 | 21 | import Baby from '../../tests/machine-behaviors/baby-machine.js' 22 | 23 | import { wrap } from '../wrap.js' 24 | 25 | import { getActorMachine } from './get-actor-machine.js' 26 | 27 | test('getActorMachine()', async t => { 28 | const machine = Baby.machine.withContext(Baby.initialContext()) 29 | const wrappedMachine = wrap(machine) 30 | const targetMachine = getActorMachine(wrappedMachine) 31 | t.equal(targetMachine, machine, 'should return actor machine') 32 | }) 33 | -------------------------------------------------------------------------------- /src/impls/get-actor-machine.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Mailbox - https://github.com/huan/mailbox 3 | * author: Huan LI (李卓桓) 4 | * Dec 2021 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | /** 19 | * Mailbox provides the address for XState Actors: 20 | * @see https://xstate.js.org/docs/guides/actors.html#actor-api 21 | */ 22 | 23 | import type { AnyStateMachine } from 'xstate' 24 | 25 | /** 26 | * The wrapped machine only have one child: the original actor machine. 27 | * 28 | * @param wrappedActorMachine 29 | * @returns 30 | */ 31 | export const getActorMachine = (wrappedActorMachine: AnyStateMachine) => { 32 | const services = wrappedActorMachine.options.services 33 | if (!services) { 34 | throw new Error('no services provided in the wrappedMachine!') 35 | } 36 | 37 | const originalActorMachine = Object.values(services)[0] 38 | if (!originalActorMachine) { 39 | throw new Error('no actor machine found in wrappedMachine!') 40 | } 41 | 42 | return originalActorMachine as AnyStateMachine 43 | } 44 | -------------------------------------------------------------------------------- /src/impls/mailbox-implementation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Mailbox - https://github.com/huan/mailbox 3 | * author: Huan LI (李卓桓) 4 | * Dec 2021 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | import 'symbol-observable' 19 | 20 | /** 21 | * Mailbox provides the address for XState Actors: 22 | * @see https://xstate.js.org/docs/guides/actors.html#actor-api 23 | */ 24 | 25 | /* eslint-disable sort-keys */ 26 | import { 27 | StateMachine, 28 | EventObject, 29 | Interpreter, 30 | interpret, 31 | InterpreterOptions, 32 | AnyStateMachine, 33 | AnyInterpreter, 34 | } from 'xstate' 35 | import { Subject, Observer, Unsubscribable } from 'rxjs' 36 | 37 | import * as duck from '../duck/mod.js' 38 | import { isMailboxType } from '../is/mod.js' 39 | import type { Context } from '../context/mod.js' 40 | 41 | import type { Mailbox, Options } from '../interface.js' 42 | 43 | import type { Address } from './address-interface.js' 44 | import { getActorMachine } from './get-actor-machine.js' 45 | import { AddressImpl } from './address-implementation.js' 46 | 47 | /** 48 | * The Mailbox Class Implementation 49 | */ 50 | export class MailboxImpl< 51 | TEvent extends EventObject = EventObject, 52 | > implements Mailbox { 53 | 54 | /** 55 | * Address of the Mailbox 56 | */ 57 | readonly address: Address 58 | readonly id: string // string value of address 59 | 60 | /** 61 | * XState interpreter for Mailbox 62 | */ 63 | private readonly _interpreter: Interpreter< 64 | Context, 65 | any, 66 | duck.Event[keyof duck.Event] | { type: TEvent['type'] } 67 | > 68 | 69 | /** 70 | * Open flag: whether the Mailbox is opened 71 | */ 72 | private _opened:boolean = false 73 | 74 | /** 75 | * RxJS Subject for all events 76 | */ 77 | private _subject: Subject = new Subject() 78 | 79 | /** 80 | * @private This is not a public API 81 | * It's only for private usage 82 | * and may be changed or removed without prior notice. 83 | */ 84 | readonly internal: { 85 | /** 86 | * Mailbox machine & interpreter (wrapped the original StateMachine) 87 | */ 88 | machine : AnyStateMachine, 89 | interpreter : AnyInterpreter, 90 | /** 91 | * Interpreter & Machine for the actor (managed child) machine in Mailbox 92 | */ 93 | actor: { 94 | machine : AnyStateMachine, 95 | interpreter? : AnyInterpreter, 96 | }, 97 | } 98 | 99 | constructor ( 100 | /** 101 | * The wrapped original StateMachine, by the wrap() function for satisfing the Mailbox Queue API 102 | */ 103 | wrappedMachine: StateMachine< 104 | Context, 105 | any, 106 | duck.Event[keyof duck.Event] | { type: TEvent['type'] }, 107 | any, 108 | any, 109 | any, 110 | any 111 | >, 112 | options: Options = {}, 113 | ) { 114 | // console.info('MailboxOptions', options) 115 | 116 | const interpretOptions: Partial = { 117 | devTools: options.devTools, 118 | } 119 | if (typeof options.logger === 'function') { 120 | // If the `logger` key has been set, then the value must be function 121 | // The interpret function can not accept a { logger: undefined } option 122 | interpretOptions.logger = options.logger 123 | } 124 | 125 | this._interpreter = interpret(wrappedMachine, interpretOptions) 126 | this._interpreter.onEvent(event => { 127 | if (/^xstate\./i.test(event.type)) { 128 | // 1. skip for XState system events 129 | // return 130 | } else if (isMailboxType(event.type) && ![ 131 | duck.Type.DEAD_LETTER, 132 | duck.Type.ACTOR_REPLY, 133 | ].includes(event.type)) { 134 | // 2. skip for Mailbox system events except DEAD_LETTER & ACTOR_REPLY 135 | // return 136 | } else if (duck.Type.ACTOR_REPLY === event.type) { 137 | // 3. unwrap Actor Reply message and emit it 138 | this._subject.next((event as ReturnType).payload.message as TEvent) 139 | } else { 140 | // 3. propagate event to the Mailbox Subject 141 | this._subject.next(event as TEvent) 142 | } 143 | }) 144 | 145 | this.address = AddressImpl.from(this._interpreter.sessionId) 146 | this.id = this._interpreter.sessionId 147 | 148 | this.internal = { 149 | machine: wrappedMachine, 150 | interpreter: this._interpreter, 151 | actor: { 152 | machine: getActorMachine(wrappedMachine), 153 | }, 154 | } 155 | } 156 | 157 | /** 158 | * Mailbox to string should be the sessionId (address id) 159 | */ 160 | toString () { 161 | return String(this.address) 162 | } 163 | 164 | /** 165 | * Send EVENT to the Mailbox Address 166 | */ 167 | send (event: TEvent | TEvent['type']): void { 168 | if (!this._opened) { 169 | this.open() 170 | } 171 | this._interpreter.send(event) 172 | } 173 | 174 | /** 175 | * Open the Mailbox for kick it started 176 | * The mailbox will be opened automatically when the first event is sent. 177 | */ 178 | open (): void { 179 | if (this._opened) { 180 | return 181 | } 182 | 183 | this._interpreter.start() 184 | this._opened = true 185 | 186 | /** 187 | * The wrapped machine has only one child machine, which is the original actor machine. 188 | * 189 | * Huan(202203): FIXME: 190 | * will ` ActorRef as AnyInterpreter` be a problem? 191 | * 192 | * SO: Get first value from iterator 193 | * @link https://stackoverflow.com/questions/32539354/how-to-get-the-first-element-of-set-in-es6-ecmascript-2015#comment79971478_32539929 194 | */ 195 | this.internal.actor.interpreter = this._interpreter.children 196 | .values() 197 | .next() 198 | .value as AnyInterpreter 199 | } 200 | 201 | /** 202 | * Close the Mailbox for disposing it 203 | */ 204 | close (): void { 205 | this._interpreter.stop() 206 | 207 | this.internal.actor.interpreter = undefined 208 | this._opened = false 209 | } 210 | 211 | /** 212 | * RxJS Observable 213 | */ 214 | [Symbol.observable] (): this { return this } 215 | /** 216 | * Huan(202205): we have a polyfill for Symbol.observable 217 | * but why RxJS still use `@@observable`? 218 | * FIXME: remove `@@observable` 219 | */ 220 | ['@@observable'] (): this { return this } 221 | 222 | subscribe (observer: Partial>): Unsubscribable { 223 | return this._subject.subscribe(observer) 224 | } 225 | 226 | } 227 | -------------------------------------------------------------------------------- /src/impls/mod.ts: -------------------------------------------------------------------------------- 1 | export * from './address-implementation.js' 2 | export * from './address-interface.js' 3 | export * from './mailbox-implementation.js' 4 | export * from '../interface.js' 5 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Mailbox - https://github.com/huan/mailbox 3 | * author: Huan LI (李卓桓) 4 | * Dec 2021 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | import 'symbol-observable' 19 | 20 | /** 21 | * Mailbox provides the address for XState Actors: 22 | * @see https://xstate.js.org/docs/guides/actors.html#actor-api 23 | */ 24 | import type { EventObject, InterpreterOptions } from 'xstate' 25 | import type { Subscribable } from 'rxjs' 26 | 27 | import type { Address } from './impls/address-interface.js' 28 | 29 | export interface Options { 30 | capacity? : number 31 | logger? : InterpreterOptions['logger'], 32 | devTools? : InterpreterOptions['devTools'], 33 | } 34 | 35 | /** 36 | * The Mailbox Interface 37 | */ 38 | export interface Mailbox< 39 | TEvent extends EventObject = EventObject, 40 | > extends Subscribable { 41 | /** 42 | * XState Actor: 43 | * `send()` method will satisfy the XState `isActor()` type guard 44 | * @link https://github.com/statelyai/xstate/blob/4cf89b5f9cf645f741164d23e3bc35dd7c5706f6/packages/core/src/utils.ts#L547-L549 45 | */ 46 | send (event: TEvent | TEvent['type']): void 47 | 48 | address: Address 49 | id: string // String value of the address 50 | 51 | open (): void 52 | close (): void 53 | 54 | /** 55 | * RxJS: How to Use Interop Observables 56 | * @link https://ncjamieson.com/how-to-use-interop-observables/ 57 | * 58 | * @method Symbol.observable 59 | * @return {Observable} this instance of the observable 60 | */ 61 | [Symbol.observable](): this 62 | /** 63 | * Huan(202205): we have a polyfill for Symbol.observable 64 | * but why RxJS still use `@@observable`? 65 | * FIXME: remove `@@observable` 66 | */ 67 | ['@@observable'](): this 68 | } 69 | -------------------------------------------------------------------------------- /src/is/is-address.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { test } from 'tstest' 5 | import { createMachine } from 'xstate' 6 | 7 | import { AddressImpl } from '../impls/address-implementation.js' 8 | import { from } from '../from.js' 9 | 10 | import { isAddress } from './is-address.js' 11 | 12 | test('isAddress() true', async t => { 13 | const ID = 'id' 14 | const address = AddressImpl.from(ID) 15 | 16 | t.ok(isAddress(address), 'should recognize address instance') 17 | }) 18 | 19 | test('isAddress() false', async t => { 20 | t.notOk(isAddress({}), 'should recognize non-address object') 21 | }) 22 | 23 | test('isAddress() with Mailbox', async t => { 24 | const machine = createMachine({}) 25 | const mailbox = from(machine) 26 | t.notOk(isAddress(mailbox), 'should identify that the mailbox instance not an address') 27 | }) 28 | -------------------------------------------------------------------------------- /src/is/is-address.ts: -------------------------------------------------------------------------------- 1 | import type { Address } from '../impls/address-interface.js' 2 | 3 | export function isAddress (value: any): value is Address { 4 | return !!value 5 | && typeof value.send === 'function' 6 | && typeof value.id === 'string' 7 | && typeof value.condNotSame === 'function' 8 | && typeof value.toString === 'function' 9 | && value.toString() === value.id 10 | } 11 | -------------------------------------------------------------------------------- /src/is/is-mailbox-type.ts: -------------------------------------------------------------------------------- 1 | import * as types from '../duck/types.js' 2 | 3 | /** 4 | * The default mailbox consists of two queues of messages: system messages and user messages. 5 | * 6 | * The system messages are used internally by the Actor Context to suspend and resume mailbox processing in case of failure. 7 | * System messages are also used by internally to manage the Actor, 8 | * e.g. starting, stopping and restarting it. 9 | * 10 | * User messages are sent to the actual Actor. 11 | * 12 | * @link https://proto.actor/docs/mailboxes/ 13 | */ 14 | export const isMailboxType = (type?: null | string): boolean => !!type && Object.values(types).includes(type) 15 | -------------------------------------------------------------------------------- /src/is/is-mailbox.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { test } from 'tstest' 5 | import { createMachine } from 'xstate' 6 | 7 | import { AddressImpl } from '../impls/mod.js' 8 | import { from } from '../from.js' 9 | 10 | import { isMailbox } from './is-mailbox.js' 11 | test('isMailbox() true', async t => { 12 | const machine = createMachine({}) 13 | const mailbox = from(machine) 14 | 15 | t.ok(isMailbox(mailbox), 'should recognize mailbox instance') 16 | }) 17 | 18 | test('isMailbox() false', async t => { 19 | t.notOk(isMailbox({}), 'should recognize non-mailbox object') 20 | }) 21 | 22 | test('isMailbox with Address', async t => { 23 | const ID = 'id' 24 | const address = AddressImpl.from(ID) 25 | 26 | t.notOk(isMailbox(address), 'should identify an address instance is not Mailbox') 27 | }) 28 | -------------------------------------------------------------------------------- /src/is/is-mailbox.ts: -------------------------------------------------------------------------------- 1 | import type { Mailbox } from '../impls/mod.js' 2 | 3 | export function isMailbox (value: any): value is Mailbox { 4 | return !!value 5 | && typeof value.send === 'function' 6 | && typeof value.id === 'string' 7 | && value.address 8 | && typeof value.address.send === 'function' 9 | && typeof value.address.id === 'string' 10 | && value.id === value.address.id 11 | } 12 | -------------------------------------------------------------------------------- /src/is/mod.ts: -------------------------------------------------------------------------------- 1 | export * from './is-address.js' 2 | export * from './is-mailbox-type.js' 3 | export * from './is-mailbox.js' 4 | -------------------------------------------------------------------------------- /src/mailbox-id.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { mailboxId, wrappedId } from './mailbox-id.js' 6 | 7 | test('mailboxId()', async t => { 8 | const FIXTURE = [ 9 | [ 'Test', 'Test' ], 10 | ] as const 11 | 12 | for (const [ actorMachineId, expected ] of FIXTURE) { 13 | t.equal(mailboxId(actorMachineId), expected, `should return ${expected} for ${actorMachineId}`) 14 | } 15 | }) 16 | 17 | test('wrappedId()', async t => { 18 | const FIXTURE = [ 19 | [ 'Test', 'Test' ], 20 | ] as const 21 | 22 | for (const [ actorMachineId, expected ] of FIXTURE) { 23 | t.equal(wrappedId(actorMachineId), expected, `should return ${expected} for ${actorMachineId}`) 24 | } 25 | }) 26 | -------------------------------------------------------------------------------- /src/mailbox-id.ts: -------------------------------------------------------------------------------- 1 | export const mailboxId = (actorMachineId: string) => `${actorMachineId}` 2 | export const wrappedId = (actorMachineId: string) => `${mailboxId(actorMachineId)}` 3 | -------------------------------------------------------------------------------- /src/mods/helpers.ts: -------------------------------------------------------------------------------- 1 | export { isMailboxType } from '../is/mod.js' 2 | export { validate } from '../validate.js' 3 | -------------------------------------------------------------------------------- /src/mods/impls.ts: -------------------------------------------------------------------------------- 1 | export { 2 | MailboxImpl as Mailbox, 3 | AddressImpl as Address, 4 | } from '../impls/mod.js' 5 | -------------------------------------------------------------------------------- /src/mods/mod.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /** 3 | * Wechaty Open Source Software - https://github.com/wechaty 4 | * 5 | * @copyright 2022 Huan LI (李卓桓) , and 6 | * Wechaty Contributors . 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License") 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * 20 | */ 21 | import { test } from 'tstest' 22 | 23 | import * as mod from './mod.js' 24 | 25 | test('mod.*', async t => { 26 | t.ok(mod.from instanceof Function, 'should export from()') 27 | t.ok(mod.wrap instanceof Function, 'should export wrap()') 28 | t.ok(mod.isMailbox instanceof Function, 'should export isMailbox()') 29 | t.ok(mod.isAddress instanceof Function, 'should export isAddress()') 30 | t.ok(mod.VERSION, 'should export VERSION') 31 | }) 32 | 33 | test('mod.actions.*', async t => { 34 | t.ok(mod.actions, 'should export actions') 35 | t.ok(mod.actions.idle instanceof Function, 'should export idle') 36 | t.ok(mod.actions.proxy instanceof Function, 'should export proxy') 37 | t.ok(mod.actions.reply instanceof Function, 'should export reply') 38 | t.ok(mod.actions.send instanceof Function, 'should export send') 39 | }) 40 | 41 | test('mod.nil.*', async t => { 42 | t.ok(mod.nil, 'should export nil') 43 | t.ok(mod.nil.address instanceof mod.impls.Address, 'should export address') 44 | t.ok(mod.nil.mailbox instanceof mod.impls.Mailbox, 'should export mailbox') 45 | t.ok(mod.nil.machine, 'should export machine') 46 | t.ok(mod.nil.logger instanceof Function, 'should export logger') 47 | }) 48 | 49 | test('helpers.*', async t => { 50 | t.ok(mod.helpers, 'should export helpers') 51 | t.ok(mod.helpers.isMailboxType, 'should export isMailboxType') 52 | t.ok(mod.helpers.validate, 'should export validate') 53 | }) 54 | 55 | test('mod.impls.*', async t => { 56 | t.ok(mod.impls, 'should export impls') 57 | t.ok(mod.impls.Address instanceof Function, 'should export Address') 58 | t.ok(mod.impls.Mailbox instanceof Function, 'should export Mailbox') 59 | }) 60 | 61 | test('mod.Event.*', async t => { 62 | t.ok(mod.Event, 'should export Event') 63 | t.ok(mod.Event.ACTOR_IDLE, 'should export Event.ACTOR_IDLE') 64 | }) 65 | 66 | test('mod.Type.*', async t => { 67 | t.ok(mod.Type, 'should export Type') 68 | t.ok(mod.Type.ACTOR_IDLE, 'should export Type.ACTOR_IDLE') 69 | }) 70 | -------------------------------------------------------------------------------- /src/mods/mod.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | Mailbox as Interface, 3 | Options, 4 | Address, 5 | } from '../impls/mod.js' 6 | export { 7 | isMailbox, 8 | isAddress, 9 | } from '../is/mod.js' 10 | export * as actions from '../actions/mod.js' 11 | export { 12 | Event, 13 | Type, 14 | } from '../duck/mod.js' 15 | export { 16 | duckularize, 17 | type Duckula, 18 | } from '../duckula/mod.js' 19 | 20 | export { from } from '../from.js' 21 | export { wrap } from '../wrap.js' 22 | 23 | export * as nil from '../nil.js' 24 | export { VERSION } from '../version.js' 25 | 26 | export * as helpers from './helpers.js' 27 | export * as impls from './impls.js' 28 | -------------------------------------------------------------------------------- /src/nil.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import { createMachine } from 'xstate' 21 | 22 | import type { Options } from './impls/mod.js' 23 | 24 | import { from } from './from.js' 25 | 26 | /** 27 | * Null destinations for Machine, Mailbox, Address, and Logger 28 | */ 29 | const machine = createMachine<{}>({}) 30 | const mailbox = from(machine) 31 | mailbox.open() 32 | 33 | const address = mailbox.address 34 | 35 | const logger: Options['logger'] = () => {} 36 | 37 | export { 38 | mailbox, 39 | machine, 40 | address, 41 | logger, 42 | } 43 | -------------------------------------------------------------------------------- /src/testing-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | /* eslint-disable no-redeclare */ 21 | // interface DebugPayload { 22 | // debug?: string 23 | // } 24 | 25 | function stripPayloadDebug (event: Object): Object 26 | function stripPayloadDebug (eventList: Object[]): Object[] 27 | 28 | function stripPayloadDebug ( 29 | event: Object | Object[], 30 | ): Object | Object[] { 31 | if (Array.isArray(event)) { 32 | return event.map(e => stripPayloadDebug(e)) 33 | } 34 | 35 | if ('payload' in event && 'data' in (event['payload'] as any)) { 36 | (event['payload'] as any).data = undefined 37 | } 38 | 39 | return event 40 | } 41 | 42 | export { 43 | stripPayloadDebug, 44 | } 45 | -------------------------------------------------------------------------------- /src/validate.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | import { 3 | test, 4 | } from 'tstest' 5 | 6 | import { 7 | createMachine, 8 | } from 'xstate' 9 | 10 | import DingDong from '../tests/machine-behaviors/ding-dong-machine.js' 11 | import Baby from '../tests/machine-behaviors/baby-machine.js' 12 | 13 | import { validate } from './validate.js' 14 | 15 | test('validate() DingDong & Baby machines', async t => { 16 | t.ok(validate(DingDong.machine), 'should be valid for DingDong.machine') 17 | t.ok(validate(Baby.machine), 'should be valid for Baby.machine') 18 | }) 19 | 20 | test('validate() empty machine', async t => { 21 | const emptyMachine = createMachine({}) 22 | t.throws(() => validate(emptyMachine), 'should not valid for an empty machine') 23 | }) 24 | -------------------------------------------------------------------------------- /src/validate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2022 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | /* eslint-disable sort-keys */ 21 | import assert from 'assert' 22 | import { 23 | StateMachine, 24 | createMachine, 25 | interpret, 26 | AnyEventObject, 27 | Interpreter, 28 | actions, 29 | EventObject, 30 | } from 'xstate' 31 | 32 | import * as duck from './duck/mod.js' 33 | import { isMailboxType } from './is/mod.js' 34 | import * as context from './context/mod.js' 35 | 36 | /** 37 | * Make the machine the child of the container to ready for testing 38 | * because the machine need to use `sendParent` to send events to parent 39 | */ 40 | function container (machine: StateMachine) { 41 | const CHILD_ID = 'mailbox-address-validating-child-id' 42 | const parentMachine = createMachine({ 43 | invoke: { 44 | id: CHILD_ID, 45 | src: machine, 46 | }, 47 | initial: 'testing', 48 | states: { 49 | testing: { 50 | on: { 51 | '*': { 52 | actions: actions.choose([ { 53 | /** 54 | * skip all: 55 | * 1. Mailbox.Types (system messages): those events is for controling Mailbox only 56 | * 2. child original messages 57 | * 58 | * Send all other events to the child 59 | */ 60 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 61 | cond: (ctx, e, meta) => true 62 | && !isMailboxType(e.type) 63 | && !context.cond.isEventFrom(CHILD_ID)(ctx, e, meta), 64 | actions: [ 65 | actions.send((_, e) => e, { to: CHILD_ID }), 66 | ], 67 | } ]), 68 | }, 69 | }, 70 | }, 71 | }, 72 | }) 73 | return parentMachine 74 | } 75 | 76 | /** 77 | * Initialization with ACTOR_IDLE event 78 | * 79 | * A mailbox-addressable machine MUST send parent ACTOR_IDLE right after it has been initialized 80 | * (or the mailbox can not know when the machine is ready to process events) 81 | * 82 | */ 83 | function validateInitializing ( 84 | machine: StateMachine, 85 | ) { 86 | const eventList: AnyEventObject[] = [] 87 | const interpreter = interpret(machine) 88 | .onEvent(e => eventList.push(e)) 89 | .start() as unknown as Interpreter // Huan(202203): FIXME: remove `as unknown as` 90 | 91 | const EXPECTED_INIT_EVENT_TYPES = [ 92 | 'xstate.init', 93 | duck.Type.ACTOR_IDLE, 94 | ] 95 | // console.info(eventList) 96 | const actualInitEvents = eventList 97 | .map(e => e.type) 98 | .filter(type => EXPECTED_INIT_EVENT_TYPES.includes(type)) 99 | 100 | /** 101 | * A mailbox-addressable machine MUST send parent ACTOR_IDLE right after it has been initialized 102 | */ 103 | assert.deepEqual(actualInitEvents, EXPECTED_INIT_EVENT_TYPES, 'should send parent ACTOR_IDLE right after it has been initialized') 104 | 105 | return [ interpreter, eventList ] as const 106 | } 107 | 108 | /** 109 | * Response each event with ACTOR_IDLE event 110 | * one event will get one ACTOR_IDLE event back 111 | */ 112 | function validateReceiveFormOtherEvent ( 113 | interpreter: Interpreter, 114 | eventList: AnyEventObject[], 115 | ): void { 116 | eventList.length = 0 117 | interpreter.send(String(Math.random())) 118 | 119 | // console.info('eventList:', eventList) 120 | 121 | const actualIdleEvents = eventList 122 | .map(e => e.type) 123 | .filter(t => t === duck.Type.ACTOR_IDLE) 124 | const EXPECTED_ACTOR_IDLE_EVENTS = [ duck.Type.ACTOR_IDLE ] 125 | assert.deepEqual( 126 | actualIdleEvents, 127 | EXPECTED_ACTOR_IDLE_EVENTS, 128 | 'Mailbox need the child machine to respond ACTOR_IDLE event to parent immediately whenever it has received one other event', 129 | ) 130 | } 131 | 132 | /** 133 | * Response each event with ACTOR_IDLE event 134 | * ten events will get ten ACTOR_IDLE events back 135 | */ 136 | function validateReceiveFormOtherEvents ( 137 | interpreter: Interpreter, 138 | eventList: AnyEventObject[], 139 | ): void { 140 | const TOTAL_EVENT_NUM = 10 141 | eventList.length = 0 142 | const randomEvents = [ ...Array(TOTAL_EVENT_NUM).keys() ] 143 | .map(i => String( 144 | i + Math.random(), 145 | )) 146 | const EXPECTED_ACTOR_IDLE_EVENTS = Array 147 | .from({ length: TOTAL_EVENT_NUM }) 148 | .fill(duck.Type.ACTOR_IDLE) 149 | interpreter.send(randomEvents) 150 | const actualIdelEvents = eventList 151 | .map(e => e.type) 152 | .filter(t => t === duck.Type.ACTOR_IDLE) 153 | assert.deepEqual(actualIdelEvents, EXPECTED_ACTOR_IDLE_EVENTS, `should send ${TOTAL_EVENT_NUM} ACTOR_IDLE events to parent when it has finished process ${TOTAL_EVENT_NUM} of other events`) 154 | } 155 | 156 | /** 157 | * events.* is only for Mailbox system. 158 | * They should not be sent to child machine. 159 | */ 160 | function validateSkipMailboxEvents ( 161 | interpreter: Interpreter, 162 | eventList: AnyEventObject[], 163 | ): void { 164 | const mailboxEventList = Object 165 | .values(duck.Event) 166 | .map(e => e()) 167 | 168 | mailboxEventList.forEach(mailboxEvent => { 169 | eventList.length = 0 170 | interpreter.send(mailboxEvent) 171 | const actualEvents = eventList.filter(e => e.type !== mailboxEvent.type) 172 | assert.deepEqual(actualEvents, [], `should skip ${mailboxEvent.type} event`) 173 | }) 174 | } 175 | 176 | /** 177 | * Throw if the machine is not a valid Mailbox-addressable machine 178 | * 179 | * Validate a state machine for satisfying the Mailbox address protocol: 180 | * 1. skip all EVENTs send from mailbox itself (Mailbox.*) 181 | * 2. send parent `events.ACTOR_IDLE()` event after each received events and back to the idle state 182 | * 183 | * @returns 184 | * Success: will return true 185 | * Failure: will throw an error 186 | */ 187 | function validate ( 188 | machine: StateMachine, 189 | ): boolean { 190 | /** 191 | * invoke the machine within a parent machine 192 | */ 193 | const parentMachine = container(machine) 194 | 195 | /** 196 | * validate the machine initializing events 197 | */ 198 | const [ interpreter, eventList ] = validateInitializing(parentMachine) 199 | 200 | /** 201 | * Response each event with ACTOR_IDLE event 202 | * 203 | * a mailbox-addressable machine MUST send ACTOR_IDLE event to parent when it has finished process an event 204 | * (or the mailbox will stop sending any new events to it because it stays in busy state) 205 | */ 206 | validateReceiveFormOtherEvent(interpreter, eventList) 207 | /** 208 | * Multiple events will get multiple ACTOR_IDLE event back 209 | */ 210 | validateReceiveFormOtherEvents(interpreter, eventList) 211 | 212 | /** 213 | * child machine should not reply any events.* events 214 | */ 215 | validateSkipMailboxEvents(interpreter, eventList) 216 | 217 | interpreter.stop() 218 | 219 | return true 220 | } 221 | 222 | export { 223 | validate, 224 | } 225 | -------------------------------------------------------------------------------- /src/version.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { VERSION } from './version.js' 6 | 7 | test('Make sure the VERSION is fresh in source code', async t => { 8 | t.equal(VERSION, '0.0.0', 'version should be 0.0.0 in source code, only updated before publish to NPM') 9 | }) 10 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * This file will be overwrite when we publish NPM module 4 | * by scripts/generate_version.ts 5 | */ 6 | 7 | export const VERSION = '0.0.0' 8 | -------------------------------------------------------------------------------- /src/wrap.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Mailbox - https://github.com/huan/mailbox 3 | * author: Huan LI (李卓桓) 4 | * Dec 2021 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | /** 20 | * Mailbox provides the address for XState Actors: 21 | * @see https://xstate.js.org/docs/guides/actors.html#actor-api 22 | */ 23 | 24 | /* eslint-disable sort-keys */ 25 | import { 26 | actions, 27 | createMachine, 28 | StateMachine, 29 | EventObject, 30 | AnyEventObject, 31 | } from 'xstate' 32 | 33 | import * as duck from './duck/mod.js' 34 | import * as context from './context/mod.js' 35 | 36 | import { IS_DEVELOPMENT } from './config.js' 37 | import { validate } from './validate.js' 38 | import type { Options } from './interface.js' 39 | import { mailboxId, wrappedId } from './mailbox-id.js' 40 | 41 | /** 42 | * Add Mailbox Queue to the targetMachine 43 | * 44 | * @param actorMachine 45 | * @param options 46 | * @returns Wrapped targetMachine with Mailbox Queue 47 | */ 48 | export function wrap < 49 | TContext extends any, 50 | TEvent extends EventObject, 51 | > ( 52 | actorMachine: StateMachine< 53 | TContext, 54 | any, 55 | TEvent 56 | >, 57 | options?: Options, 58 | ) { 59 | /** 60 | * when in developement mode, we will validate the targetMachine 61 | */ 62 | if (IS_DEVELOPMENT && !validate(actorMachine)) { 63 | throw new Error('Mailbox.wrap: invalid targetMachine!') 64 | } 65 | 66 | const MAILBOX_ID = mailboxId(actorMachine.id) 67 | const WRAPPED_ID = wrappedId(actorMachine.id) 68 | 69 | const normalizedOptions: Required = { 70 | capacity : Infinity, 71 | logger : () => {}, 72 | devTools : false, 73 | ...options, 74 | } 75 | 76 | // https://xstate.js.org/docs/guides/context.html#initial-context 77 | 78 | const machine = createMachine< 79 | context.Context, 80 | /** 81 | * add child event types to mailbox event types 82 | * 83 | * Huan(202112) TODO: remove the `TEvent['type']` and just use `TEvent` 84 | */ 85 | duck.Event[keyof duck.Event] | { type: TEvent['type'] } 86 | >({ 87 | id: MAILBOX_ID, 88 | invoke: { 89 | id: WRAPPED_ID, 90 | src: actorMachine, 91 | }, 92 | /** 93 | * initialize context: 94 | * factory call to make sure the contexts will not be modified 95 | * by mistake from other machines 96 | */ 97 | context: () => context.initialContext(), 98 | /** 99 | * Issue statelyai/xstate#2891: 100 | * The context provided to the expr inside a State 101 | * should be exactly the **context in this state** 102 | * @see https://github.com/statelyai/xstate/issues/2891 103 | */ 104 | preserveActionOrder: true, 105 | 106 | /** 107 | * Match all events no matter what state the machine currently in 108 | * and queueing them with a NEW_MESSAGE event notification 109 | */ 110 | on: { 111 | '*': { 112 | actions: context.queue.newMessage(actorMachine.id)(normalizedOptions.capacity) as any, 113 | }, 114 | }, 115 | 116 | initial: duck.State.Idle, 117 | states: { 118 | 119 | /** 120 | * 121 | * Child State.Idle: 122 | * 123 | * 1. transited to idle -> emit NEW_MESSAGE if queue size > 0 124 | * 2. received ACTOR_REPLY -> unwrap message then: 1/ reply it to the original sender, or 2/ send it to Deal Letter Queue (DLQ) 125 | * 5. received '*' -> save it to queue and emit NEW_MESSAGE if: 1/ non-internal type, and 2/ non-overflow-capacity 126 | * 3. received NEW_MESSAGE -> emit DEQUEUE 127 | * 4. received DEQUEUE -> transit to Busy 128 | * 129 | */ 130 | 131 | [duck.State.Idle]: { 132 | /** 133 | * DISPATCH event MUST be only send from child.idle 134 | * because it will drop the current message and dequeue the next one 135 | */ 136 | entry: [ 137 | actions.log('states.Idle.entry', MAILBOX_ID), 138 | actions.choose([ 139 | { 140 | cond: ctx => context.queue.size(ctx) > 0, 141 | actions: [ 142 | actions.log(ctx => [ 143 | 'states.Idle.entry NEW_MESSAGE queue size: ', 144 | context.queue.size(ctx), 145 | ' [', 146 | context.queue.message(ctx)?.type, 147 | ']@', 148 | context.origin.metaOrigin( 149 | context.queue.message(ctx), 150 | ), 151 | ].join(''), MAILBOX_ID), 152 | actions.send(ctx => duck.Event.NEW_MESSAGE(context.queue.message(ctx)!.type)), 153 | ], 154 | }, 155 | ]), 156 | ], 157 | on: { 158 | /** 159 | * Huan(202204): No matter idle or busy: the child may send reponse message at any time. 160 | * TODO: it would be better to remove the state global on.ACTOR_REPLY, just leave the duck.state.Busy.on.ACTOR_REPLY should be better. 161 | * 162 | * XState duck.state.exit.actions & micro transitions will be executed in the next state #4 163 | * @link https://github.com/huan/mailbox/issues/4 164 | */ 165 | [duck.Type.ACTOR_REPLY]: { 166 | actions: [ 167 | actions.log( 168 | (ctx, e, { _event }) => [ 169 | 'states.Idle.on.ACTOR_REPLY [', 170 | e.payload.message.type, 171 | ']@', 172 | _event.origin, 173 | ' -> [', 174 | context.request.message(ctx)?.type, 175 | ']@', 176 | context.request.address(ctx), 177 | ].join(''), 178 | MAILBOX_ID, 179 | ), 180 | context.child.actorReply(actorMachine.id), 181 | ], 182 | }, 183 | 184 | /** 185 | * Huan(202201) FIXME: remove the `as any` at the end of the `actions.log(...)`. 186 | * Is this a bug in xstate? to be confirmed. (hope xstate@5 will fix it) 187 | */ 188 | [duck.Type.NEW_MESSAGE]: { 189 | actions: [ 190 | actions.log( 191 | (_, e) => `states.Idle.on.NEW_MESSAGE [${e.payload.type}]`, 192 | MAILBOX_ID, 193 | ) as any, // <- Huan(202204) FIXME: remove any 194 | actions.send(ctx => duck.Event.DEQUEUE(context.queue.message(ctx)!)), 195 | ], 196 | }, 197 | [duck.Type.DEQUEUE]: duck.State.Busy, 198 | }, 199 | }, 200 | 201 | /** 202 | * 203 | * Child State.Busy 204 | * 205 | * 1. transited to busy -> unwrap message from DEQUEUE event, then a) save it to `context.message`, and b) send it to child 206 | * 2. received ACTOR_REPLY -> unwrap message from ACTOR_REPLY event, then reply it to the sender of `context.message` 207 | * 3. received CHLID_IDLE -> transit to Idle 208 | * 209 | * 4. assignDequeue 210 | * 5. assignEmptyQueue if queue size <= 0 211 | */ 212 | [duck.State.Busy]: { 213 | entry: [ 214 | actions.log( 215 | (_, e) => [ 216 | 'states.Busy.entry DEQUEUE [', 217 | e.payload.message.type, 218 | ']@', 219 | context.origin.metaOrigin(e.payload.message), 220 | ].join(''), 221 | MAILBOX_ID, 222 | ), 223 | actions.assign({ 224 | message: (_, e) => e.payload.message, 225 | }), 226 | context.queue.dequeue, 227 | 228 | /** 229 | * 230 | * Forward the message to the actor machine 231 | * 232 | */ 233 | actions.send( 234 | (_, e) => e.payload.message, 235 | { to: WRAPPED_ID }, 236 | ), 237 | 238 | ], 239 | on: { 240 | [duck.Type.ACTOR_IDLE]: { 241 | actions: [ 242 | actions.log((_, __, meta) => `states.Busy.on.ACTOR_IDLE@${meta._event.origin}`, MAILBOX_ID) as any, 243 | ], 244 | target: duck.State.Idle, 245 | }, 246 | [duck.Type.ACTOR_REPLY]: { 247 | actions: [ 248 | actions.log( 249 | (ctx, e, meta) => [ 250 | 'states.Busy.on.ACTOR_REPLY [', 251 | e.payload.message.type, 252 | ']@', 253 | meta._event.origin, 254 | ' -> [', 255 | context.request.message(ctx)?.type, 256 | ']@', 257 | context.request.address(ctx), 258 | ].join(''), 259 | MAILBOX_ID, 260 | ), 261 | context.child.actorReply(actorMachine.id), 262 | ], 263 | }, 264 | }, 265 | exit: [ 266 | actions.choose([ 267 | { 268 | cond: ctx => context.queue.size(ctx) <= 0, 269 | actions: context.queue.emptyQueue, 270 | }, 271 | ]), 272 | ], 273 | }, 274 | }, 275 | }) 276 | return machine 277 | } 278 | -------------------------------------------------------------------------------- /tests/fixtures/smoke-testing.ts: -------------------------------------------------------------------------------- 1 | import * as Mailbox from 'mailbox' 2 | import { createMachine } from 'xstate' 3 | import assert from 'assert' 4 | 5 | async function main () { 6 | const machine = createMachine({}) 7 | const mailbox = Mailbox.from(machine) 8 | 9 | mailbox.open() 10 | assert.ok(mailbox.address, 'should get address from mailbox') 11 | assert.equal(String(mailbox.address), mailbox.id, 'should be same as mailbox.id') 12 | assert.notEqual(Mailbox.VERSION, '0.0.0', 'version must be updated instead of 0.0.0 before publish!') 13 | 14 | console.log(`Mailbox v${Mailbox.VERSION} smoke testing passed!`) 15 | } 16 | 17 | main() 18 | .catch(e => { 19 | console.error(e) 20 | process.exit(1) 21 | }) 22 | -------------------------------------------------------------------------------- /tests/integration.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { 5 | test, 6 | sinon, 7 | } from 'tstest' 8 | 9 | import { 10 | AnyEventObject, 11 | interpret, 12 | createMachine, 13 | actions, 14 | // spawn, 15 | } from 'xstate' 16 | 17 | import * as Mailbox from '../src/mods/mod.js' 18 | 19 | import DingDong from './machine-behaviors/ding-dong-machine.js' 20 | import CoffeeMaker from './machine-behaviors/coffee-maker-machine.js' 21 | import { isActionOf } from 'typesafe-actions' 22 | 23 | test('Mailbox.from(DingDong.machine) as an actor should enforce process messages one by one', async t => { 24 | const sandbox = sinon.createSandbox({ 25 | useFakeTimers: true, 26 | }) 27 | 28 | const ITEM_NUMBERS = [ ...Array(3).keys() ] 29 | const DING_EVENT_LIST = ITEM_NUMBERS.map(i => DingDong.Event.DING(i)) 30 | const DONG_EVENT_LIST = ITEM_NUMBERS.map(i => DingDong.Event.DONG(i)) 31 | 32 | const mailbox = Mailbox.from(DingDong.machine.withContext(DingDong.initialContext())) as Mailbox.impls.Mailbox 33 | mailbox.open() 34 | 35 | const TEST_ID = 'TestMachine' 36 | const testMachine = createMachine({ 37 | id: TEST_ID, 38 | on: { 39 | '*': { 40 | actions: Mailbox.actions.proxy(TEST_ID)(mailbox), 41 | }, 42 | }, 43 | }) 44 | 45 | const eventList: AnyEventObject[] = [] 46 | const interpreter = interpret(testMachine) 47 | .onTransition(s => eventList.push(s.event)) 48 | .start() 49 | 50 | DING_EVENT_LIST.forEach(e => interpreter.send(e)) 51 | 52 | await sandbox.clock.runAllAsync() 53 | eventList.forEach(e => console.info(e)) 54 | 55 | t.same( 56 | eventList.filter(isActionOf(DingDong.Event.DONG)), 57 | DONG_EVENT_LIST, 58 | `should reply total ${DONG_EVENT_LIST.length} DONG events to ${DING_EVENT_LIST.length} DING events`, 59 | ) 60 | 61 | interpreter.stop() 62 | sandbox.restore() 63 | }) 64 | 65 | test('parentMachine with invoke.src=Mailbox.address(DingDong.machine) should proxy events', async t => { 66 | const sandbox = sinon.createSandbox({ 67 | useFakeTimers: true, 68 | }) 69 | 70 | const ITEM_NUMBERS = [ ...Array(3).keys() ] 71 | 72 | const DING_EVENT_LIST = ITEM_NUMBERS.map(i => 73 | DingDong.Event.DING(i), 74 | ) 75 | const DONG_EVENT_LIST = ITEM_NUMBERS.map(i => 76 | DingDong.Event.DONG(i), 77 | ) 78 | 79 | const mailbox = Mailbox.from(DingDong.machine.withContext(DingDong.initialContext())) as Mailbox.impls.Mailbox 80 | const machine = mailbox.internal.machine 81 | 82 | const CHILD_ID = 'mailbox-child-id' 83 | 84 | const parentMachine = createMachine({ 85 | invoke: { 86 | id: CHILD_ID, 87 | src: machine, 88 | /** 89 | * Huan(202112): autoForward event will not set `origin` to the forwarder. 90 | * think it like a SNAT/DNAT in iptables? 91 | */ 92 | // autoForward: true, 93 | }, 94 | initial: 'testing', 95 | states: { 96 | testing: { 97 | on: { 98 | [DingDong.Type.DING]: { 99 | actions: actions.send( 100 | (_, e) => e, 101 | { to: CHILD_ID }, 102 | ), 103 | }, 104 | }, 105 | }, 106 | }, 107 | }) 108 | 109 | const interpreter = interpret( 110 | parentMachine, 111 | ) 112 | 113 | const eventList: AnyEventObject[] = [] 114 | interpreter 115 | .onTransition(s => eventList.push(s.event)) 116 | .start() 117 | 118 | DING_EVENT_LIST.forEach(e => interpreter.send(e)) 119 | 120 | await sandbox.clock.runAllAsync() 121 | 122 | t.same( 123 | eventList.filter(e => e.type === DingDong.Type.DONG), 124 | DONG_EVENT_LIST, 125 | `should get replied DONG events from all(${DONG_EVENT_LIST.length}) DING events`, 126 | ) 127 | 128 | interpreter.stop() 129 | sandbox.restore() 130 | }) 131 | 132 | test('Mailbox.from(CoffeeMaker.machine) as an actor should enforce process messages one by one', async t => { 133 | const sandbox = sinon.createSandbox({ 134 | useFakeTimers: true, 135 | }) 136 | 137 | const ITEM_NUMBERS = [ ...Array(3).keys() ] 138 | 139 | const MAKE_ME_COFFEE_EVENT_LIST = ITEM_NUMBERS.map(i => 140 | CoffeeMaker.Event.MAKE_ME_COFFEE(String(i)), 141 | ) 142 | const EXPECTED_COFFEE_EVENT_LIST = ITEM_NUMBERS.map(i => 143 | CoffeeMaker.Event.COFFEE(String(i)), 144 | ) 145 | 146 | const mailbox = Mailbox.from(CoffeeMaker.machine.withContext(CoffeeMaker.initialContext())) as Mailbox.impls.Mailbox 147 | mailbox.open() 148 | 149 | const TEST_ID = 'TestMachine' 150 | const testMachine = createMachine({ 151 | id: TEST_ID, 152 | on: { 153 | '*': { 154 | actions: Mailbox.actions.proxy(TEST_ID)(mailbox), 155 | }, 156 | }, 157 | }) 158 | 159 | const eventList: AnyEventObject[] = [] 160 | const interpreter = interpret(testMachine) 161 | .onTransition(s => eventList.push(s.event)) 162 | .start() 163 | 164 | /** 165 | * XState machine behavior different with interpreter.send(eventList) and eventList.forEach(e => interpreter.send(e)) #8 166 | * @link https://github.com/wechaty/bot5-assistant/issues/8 167 | */ 168 | MAKE_ME_COFFEE_EVENT_LIST.forEach(e => interpreter.send(e)) 169 | // Bug: wechaty/bot5-assistant#8 170 | // interpreter.send(MAKE_ME_COFFEE_EVENT_LIST) 171 | 172 | await sandbox.clock.runAllAsync() 173 | // eventList.forEach(e => console.info(e)) 174 | 175 | t.same( 176 | eventList 177 | .filter(isActionOf(CoffeeMaker.Event.COFFEE)), 178 | EXPECTED_COFFEE_EVENT_LIST, 179 | `should reply dead letter of total ${EXPECTED_COFFEE_EVENT_LIST.length} COFFEE events to ${MAKE_ME_COFFEE_EVENT_LIST.length} MAKE_ME_COFFEE events`, 180 | ) 181 | 182 | interpreter.stop() 183 | sandbox.restore() 184 | }) 185 | -------------------------------------------------------------------------------- /tests/machine-behaviors/baby-machine.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { 5 | test, 6 | sinon, 7 | } from 'tstest' 8 | 9 | import { 10 | AnyEventObject, 11 | createMachine, 12 | interpret, 13 | Interpreter, 14 | } from 'xstate' 15 | 16 | import * as Mailbox from '../../src/mods/mod.js' 17 | 18 | import Baby from './baby-machine.js' 19 | 20 | test('babyMachine smoke testing with asleep under mock clock', async t => { 21 | const sandbox = sinon.createSandbox({ 22 | useFakeTimers: true, 23 | }) 24 | 25 | const CHILD_ID = 'testing-child-id' 26 | 27 | const proxyMachine = createMachine({ 28 | id: 'parent', 29 | initial: 'testing', 30 | invoke: { 31 | id: CHILD_ID, 32 | src: Baby.machine.withContext({}), 33 | }, 34 | states: { 35 | testing: {}, 36 | }, 37 | }) 38 | 39 | const proxyEventList: AnyEventObject[] = [] 40 | const proxyInterpreter = interpret(proxyMachine) 41 | .onTransition(s => { 42 | proxyEventList.push(s.event) 43 | 44 | console.info('onTransition (Parent): ') 45 | console.info(' - states:', s.value) 46 | console.info(' - event:', s.event.type) 47 | console.info() 48 | }) 49 | .start() 50 | 51 | await sandbox.clock.runAllAsync() 52 | 53 | const babyEventList: AnyEventObject[] = [] 54 | const babyRef = () => proxyInterpreter.getSnapshot().children[CHILD_ID] as Interpreter 55 | babyRef().onTransition(s => { 56 | babyEventList.push(s.event) 57 | 58 | console.info('onTransition (Baby): ') 59 | console.info(' - states:', s.value) 60 | console.info(' - event:', s.event.type) 61 | console.info() 62 | }) 63 | 64 | const babyState = () => babyRef().getSnapshot().value 65 | const babyContext = () => babyRef().getSnapshot().context as ReturnType 66 | 67 | const SLEEP_MS = 10 68 | /** 69 | * summary of the below ms should be equal to SLEEP_MS: 70 | */ 71 | const BEFORE_CRY_MS = 3 72 | const AFTER_CRY_MS = 4 73 | const AFTER_SLEEP_MS = 3 74 | 75 | t.equal(babyState(), Baby.State.awake, 'babyMachine initial state should be awake') 76 | t.same(babyEventList, [ 77 | { type: 'xstate.init' }, 78 | ], 'should have initial event list from child') 79 | t.same( 80 | proxyEventList, 81 | [ 82 | { type: 'xstate.init' }, 83 | Mailbox.Event.ACTOR_IDLE(), 84 | Mailbox.Event.ACTOR_REPLY( 85 | Baby.Event.PLAY(), 86 | ), 87 | ], 88 | 'should have initial event ACTOR_IDLE & ACTOR_REPLY(PLAY) sent', 89 | ) 90 | 91 | /** 92 | * SLEEP 93 | */ 94 | proxyEventList.length = 0 95 | babyEventList.length = 0 96 | babyRef().send(Baby.Event.SLEEP(SLEEP_MS)) 97 | t.equal(babyState(), Baby.State.asleep, 'babyMachine state should be asleep') 98 | t.equal(babyContext().ms, SLEEP_MS, `babyMachine context.ms should be ${SLEEP_MS}`) 99 | t.same(babyEventList.map(e => e.type), [ 100 | Baby.Type.SLEEP, 101 | ], 'should have SLEEP event on child') 102 | await sandbox.clock.tickAsync(0) 103 | // proxyEventList.forEach(e => console.info(e)) 104 | t.same( 105 | proxyEventList, [ 106 | Baby.Event.EAT(), 107 | Baby.Event.REST(), 108 | Baby.Event.DREAM(), 109 | ].map(Mailbox.Event.ACTOR_REPLY), 110 | 'should have ACTOR_IDLE & ACTOR_REPLY event list for parent', 111 | ) 112 | 113 | /** 114 | * 2nd SLEEP (with concurrency) 115 | */ 116 | proxyEventList.length = 0 117 | babyEventList.length = 0 118 | babyRef().send(Baby.Event.SLEEP(SLEEP_MS * 1e9)) // a very long time 119 | t.equal(babyState(), Baby.State.asleep, 'babyMachine state should be asleep') 120 | t.equal(babyContext().ms, SLEEP_MS, `babyMachine context.ms should still be ${SLEEP_MS} (new event has been dropped by machine)`) 121 | t.same(proxyEventList, [], 'should no more response when asleep for parent') 122 | t.same(babyEventList, [ 123 | Baby.Event.SLEEP(SLEEP_MS * 1e9), 124 | ], 'should has SLEEP event on child') 125 | 126 | /** 127 | * BEFORE_CRY_MS 128 | */ 129 | proxyEventList.length = 0 130 | babyEventList.length = 0 131 | await sandbox.clock.tickAsync(BEFORE_CRY_MS) 132 | t.equal(babyState(), Baby.State.asleep, `babyMachine state should be asleep after 1st ${BEFORE_CRY_MS} ms`) 133 | t.equal(babyContext().ms, SLEEP_MS, `babyMachine context.ms should be ${SLEEP_MS} (new event has been dropped) after 1st ${BEFORE_CRY_MS} ms`) 134 | t.same(proxyEventList, [], `should no more response after 1st ${BEFORE_CRY_MS} ms for parent`) 135 | t.same(babyEventList, [], `should no more response after 1st ${BEFORE_CRY_MS} ms for parent`) 136 | 137 | /** 138 | * AFTER_CRY_MS 139 | */ 140 | proxyEventList.length = 0 141 | babyEventList.length = 0 142 | await sandbox.clock.tickAsync(AFTER_CRY_MS) 143 | t.equal(babyState(), Baby.State.asleep, `babyMachine state should be asleep after 2nd ${AFTER_CRY_MS} ms`) 144 | t.equal(babyContext().ms, SLEEP_MS, `babyMachine context.ms should be ${SLEEP_MS} (new event has been dropped) after 2nd ${AFTER_CRY_MS} ms`) 145 | t.same( 146 | proxyEventList, 147 | [ 148 | Mailbox.Event.ACTOR_REPLY(Baby.Event.CRY()), 149 | ], 150 | 'should cry in middle night (after 2nd 4 ms) for parent', 151 | ) 152 | t.same( 153 | babyEventList, 154 | [ 155 | { type: 'xstate.after(cryMs)#Baby.baby/asleep' }, 156 | ], 157 | 'should cry in middle night (after 2nd 4 ms) for child', 158 | ) 159 | 160 | /** 161 | * AFTER_SLEEP_MS 162 | */ 163 | proxyEventList.length = 0 164 | babyEventList.length = 0 165 | await sandbox.clock.tickAsync(AFTER_SLEEP_MS) 166 | t.equal(babyState(), Baby.State.awake, 'babyMachine state should be awake after sleep') 167 | t.equal(babyContext().ms, undefined, 'babyMachine context.ms should be cleared after sleep') 168 | t.same(proxyEventList.map(e => e.type), [ 169 | Mailbox.Type.ACTOR_IDLE, 170 | ], 'should be IDLE after night') 171 | 172 | /** 173 | * Final +1 (??? why? Huan(202201)) 174 | */ 175 | await sandbox.clock.tickAsync(1) 176 | t.same(proxyEventList.map(e => e.type), [ 177 | Mailbox.Type.ACTOR_IDLE, 178 | Mailbox.Type.ACTOR_REPLY, 179 | Mailbox.Type.ACTOR_REPLY, 180 | ], 'should pee after night and start paly in the morning, sent to parent') 181 | t.same(babyEventList.map(e => e.type), [ 182 | 'xstate.after(ms)#Baby.baby/asleep', 183 | ], 'should received after(ms) event for child') 184 | 185 | proxyInterpreter.stop() 186 | 187 | sandbox.restore() 188 | }) 189 | -------------------------------------------------------------------------------- /tests/machine-behaviors/baby-machine.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-redeclare */ 2 | /* eslint-disable sort-keys */ 3 | import { createAction } from 'typesafe-actions' 4 | import { 5 | createMachine, 6 | actions, 7 | } from 'xstate' 8 | 9 | import * as Mailbox from '../../src/mods/mod.js' 10 | 11 | enum State { 12 | awake = 'baby/awake', 13 | asleep = 'baby/asleep', 14 | } 15 | 16 | enum Type { 17 | SLEEP = 'baby/SLEEP', 18 | // asleep 19 | DREAM = 'baby/DREAM', 20 | CRY = 'baby/CRY', 21 | PEE = 'baby/PEE', 22 | // awake 23 | PLAY = 'baby/PLAY', 24 | REST = 'baby/REST', 25 | EAT = 'baby/EAT', 26 | } 27 | 28 | const Event = { 29 | SLEEP : createAction(Type.SLEEP, (ms: number) => ({ ms }))(), 30 | // asleep 31 | DREAM : createAction(Type.DREAM)(), 32 | CRY : createAction(Type.CRY)(), 33 | PEE : createAction(Type.PEE)(), 34 | // awake 35 | PLAY : createAction(Type.PLAY)(), 36 | REST : createAction(Type.REST)(), 37 | EAT : createAction(Type.EAT)(), 38 | } as const 39 | 40 | type Context = { ms?: number } 41 | 42 | const duckula = Mailbox.duckularize({ 43 | id: 'Baby', 44 | events: Event, 45 | states: State, 46 | initialContext: {} as Context, 47 | }) 48 | 49 | /** 50 | * (Awake)[PLAY] + [SLEEP] = (Asleep)[REST, EAT] 51 | * (Asleep)[DREAM][CRY][PEE] = (Awake) 52 | */ 53 | const machine = createMachine< 54 | ReturnType, 55 | ReturnType, 56 | any 57 | >({ 58 | id: duckula.id, 59 | initial: duckula.State.awake, 60 | states: { 61 | [duckula.State.awake]: { 62 | entry: [ 63 | actions.log((_, e, { _event }) => `states.awake.entry <- [${e.type}]@${_event.origin}`, duckula.id), 64 | Mailbox.actions.idle(duckula.id), 65 | Mailbox.actions.reply(Event.PLAY()), 66 | ], 67 | exit: [ 68 | actions.log('states.awake.exit', duckula.id), 69 | /** 70 | * FIXME: Huan(202112): uncomment the below `sendParent` line 71 | * https://github.com/statelyai/xstate/issues/2880 72 | */ 73 | // actions.sendParent(Event.EAT()), 74 | Mailbox.actions.reply(Event.EAT()), 75 | ], 76 | on: { 77 | /** 78 | * Huan(202112): 79 | * always send parent a IDLE event if the target machine received a event if it does not care it at all. 80 | * 81 | * This behavior is required for mailbox target, and it is very important because 82 | * the mailbox need to know whether the target is idle or not 83 | * by waiting a IDLE event feedback whenever it has sent an event to the target. 84 | */ 85 | '*': { 86 | target: duckula.State.awake, 87 | actions: [ 88 | actions.log((_, e, { _event }) => `states.awake.on.* <- [${e.type}]@${_event.origin || ''}`, duckula.id), 89 | ], 90 | }, 91 | [Type.SLEEP]: { 92 | target: duckula.State.asleep, 93 | actions: [ 94 | actions.log((_, e) => `states.awake.on.SLEEP ${JSON.stringify(e)}`, duckula.id), 95 | Mailbox.actions.reply(Event.REST()), 96 | ], 97 | }, 98 | }, 99 | }, 100 | [duckula.State.asleep]: { 101 | entry: [ 102 | actions.log((_, e) => `states.asleep.entry ${JSON.stringify(e)}`, duckula.id), 103 | // Huan(202112): move this assign to previous state.on(SLEEP) 104 | // FIXME: `(parameter) e: never` (after move to previous state.on.SLEEP) 105 | actions.assign({ ms: (_, e) => (e as ReturnType).payload.ms }), 106 | Mailbox.actions.reply(Event.DREAM()), 107 | ], 108 | exit: [ 109 | actions.log(_ => 'states.asleep.exit', duckula.id), 110 | actions.assign({ ms: _ => undefined }), 111 | Mailbox.actions.reply(Event.PEE()), 112 | ], 113 | after: { 114 | cryMs: { 115 | actions: Mailbox.actions.reply(Event.CRY()), 116 | }, 117 | ms: duckula.State.awake, 118 | }, 119 | }, 120 | }, 121 | }, { 122 | delays: { 123 | cryMs: ctx => { 124 | const ms = Math.floor(Number(ctx.ms) / 2) 125 | console.info('BabyMachine preMs', ms) 126 | return ms 127 | }, 128 | ms: ctx => { 129 | const ms = Number(ctx.ms) 130 | console.info('BabyMachine ms', ms) 131 | return ms 132 | }, 133 | }, 134 | }) 135 | 136 | duckula.machine = machine 137 | export default duckula as Required 138 | -------------------------------------------------------------------------------- /tests/machine-behaviors/coffee-maker-machine.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { 5 | test, 6 | sinon, 7 | } from 'tstest' 8 | 9 | import { 10 | AnyEventObject, 11 | createMachine, 12 | interpret, 13 | actions, 14 | } from 'xstate' 15 | 16 | import * as Mailbox from '../../src/mods/mod.js' 17 | 18 | import CoffeeMaker from './coffee-maker-machine.js' 19 | 20 | test('CoffeeMaker.machine smoke testing', async t => { 21 | const CUSTOMER = 'John' 22 | 23 | const sandbox = sinon.createSandbox({ 24 | useFakeTimers: true, 25 | }) 26 | 27 | const CHILD_ID = 'child' 28 | 29 | const parentMachine = createMachine({ 30 | id: 'parent', 31 | initial: 'testing', 32 | invoke: { 33 | id: CHILD_ID, 34 | src: CoffeeMaker.machine.withContext({}), 35 | }, 36 | states: { 37 | testing: { 38 | on: { 39 | [CoffeeMaker.Type.MAKE_ME_COFFEE]: { 40 | actions: actions.send((_, e) => e, { to: CHILD_ID }), 41 | }, 42 | }, 43 | }, 44 | }, 45 | }) 46 | 47 | const interpreter = interpret(parentMachine) 48 | 49 | const eventList: AnyEventObject[] = [] 50 | interpreter.onTransition(s => { 51 | eventList.push(s.event) 52 | 53 | console.info('onTransition: ') 54 | console.info(' - states:', s.value) 55 | console.info(' - event:', s.event.type) 56 | console.info() 57 | }) 58 | 59 | interpreter.start() 60 | interpreter.send(CoffeeMaker.Event.MAKE_ME_COFFEE(CUSTOMER)) 61 | t.same( 62 | eventList.map(e => e.type), 63 | [ 64 | 'xstate.init', 65 | Mailbox.Type.ACTOR_IDLE, 66 | CoffeeMaker.Type.MAKE_ME_COFFEE, 67 | ], 68 | 'should have received init/RECEIVE/MAKE_ME_COFFEE events after initializing', 69 | ) 70 | 71 | eventList.length = 0 72 | await sandbox.clock.runAllAsync() 73 | t.same( 74 | eventList 75 | .filter(e => e.type === Mailbox.Type.ACTOR_REPLY), 76 | [ 77 | Mailbox.Event.ACTOR_REPLY(CoffeeMaker.Event.COFFEE(CUSTOMER)), 78 | ], 79 | 'should have received COFFEE/RECEIVE events after runAllAsync', 80 | ) 81 | 82 | interpreter.stop() 83 | sandbox.restore() 84 | }) 85 | 86 | test('XState machine will lost incoming messages(events) when receiving multiple messages at the same time', async t => { 87 | const sandbox = sinon.createSandbox({ 88 | useFakeTimers: true, 89 | }) 90 | 91 | const ITEM_NUMBERS = [ ...Array(10).keys() ] 92 | const MAKE_ME_COFFEE_EVENT_LIST = ITEM_NUMBERS.map(i => CoffeeMaker.Event.MAKE_ME_COFFEE(String(i))) 93 | const COFFEE_EVENT_LIST = ITEM_NUMBERS.map(i => CoffeeMaker.Event.COFFEE(String(i))) 94 | 95 | const containerMachine = createMachine({ 96 | invoke: { 97 | id: 'child', 98 | src: CoffeeMaker.machine.withContext({}), 99 | }, 100 | on: { 101 | [CoffeeMaker.Type.MAKE_ME_COFFEE]: { 102 | actions: [ 103 | actions.send((_, e) => e, { to: 'child' }), 104 | ], 105 | }, 106 | }, 107 | states: {}, 108 | }) 109 | 110 | const interpreter = interpret(containerMachine) 111 | 112 | const eventList: AnyEventObject[] = [] 113 | interpreter 114 | .onTransition(s => eventList.push(s.event)) 115 | .start() 116 | 117 | MAKE_ME_COFFEE_EVENT_LIST.forEach(e => interpreter.send(e)) 118 | 119 | await sandbox.clock.runAllAsync() 120 | // eventList.forEach(e => console.info(e)) 121 | t.same( 122 | eventList 123 | .filter(e => e.type === Mailbox.Type.ACTOR_REPLY), 124 | [ 125 | COFFEE_EVENT_LIST.map(e => 126 | Mailbox.Event.ACTOR_REPLY(e), 127 | )[0], 128 | ], 129 | `should only get 1 COFFEE event no matter how many MAKE_ME_COFFEE events we sent (at the same time, total: ${MAKE_ME_COFFEE_EVENT_LIST.length})`, 130 | ) 131 | 132 | interpreter.stop() 133 | sandbox.restore() 134 | }) 135 | -------------------------------------------------------------------------------- /tests/machine-behaviors/coffee-maker-machine.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | import { createAction } from 'typesafe-actions' 3 | import { createMachine, actions } from 'xstate' 4 | 5 | import * as Mailbox from '../../src/mods/mod.js' 6 | 7 | enum State { 8 | idle = 'idle', 9 | busy = 'busy', 10 | } 11 | 12 | enum Type { 13 | MAKE_ME_COFFEE = 'MAKE_ME_COFFEE', 14 | COFFEE = 'COFFEE', 15 | } 16 | 17 | const Event = { 18 | MAKE_ME_COFFEE : createAction(Type.MAKE_ME_COFFEE, (customer: string) => ({ customer }))(), 19 | COFFEE : createAction(Type.COFFEE, (customer: string) => ({ customer }))(), 20 | } 21 | 22 | interface Context { 23 | customer?: string, 24 | } 25 | 26 | const duckula = Mailbox.duckularize({ 27 | id: 'CoffeeMaker', 28 | events: Event, 29 | states: State, 30 | initialContext: {} as Context, 31 | }) 32 | 33 | export const DELAY_MS = 10 34 | 35 | const machine = createMachine< 36 | ReturnType, 37 | ReturnType 38 | >({ 39 | id: duckula.id, 40 | initial: duckula.State.idle, 41 | states: { 42 | [duckula.State.idle]: { 43 | entry: Mailbox.actions.idle('CoffeeMaker'), 44 | on: { 45 | [duckula.Type.MAKE_ME_COFFEE]: { 46 | target: duckula.State.busy, 47 | actions: actions.assign((_, e) => ({ customer: e.payload.customer })), 48 | }, 49 | '*': duckula.State.idle, 50 | }, 51 | }, 52 | [duckula.State.busy]: { 53 | entry: [ 54 | actions.send(ctx => duckula.Event.COFFEE(ctx.customer!), { 55 | delay: DELAY_MS, 56 | }), 57 | ], 58 | on: { 59 | [duckula.Type.COFFEE]: { 60 | actions: Mailbox.actions.reply((_, e) => e), 61 | target: duckula.State.idle, 62 | }, 63 | }, 64 | exit: actions.assign({ customer: _ => undefined }), 65 | }, 66 | }, 67 | }) 68 | 69 | duckula.machine = machine 70 | export default duckula as Required 71 | -------------------------------------------------------------------------------- /tests/machine-behaviors/ding-dong-machine.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { 5 | test, 6 | sinon, 7 | } from 'tstest' 8 | 9 | import { 10 | AnyEventObject, 11 | createMachine, 12 | interpret, 13 | } from 'xstate' 14 | 15 | import * as Mailbox from '../../src/mods/mod.js' 16 | 17 | import DingDong from './ding-dong-machine.js' 18 | 19 | test('DingDong.machine process one DING event', async t => { 20 | const sandbox = sinon.createSandbox({ 21 | useFakeTimers: true, 22 | }) 23 | 24 | const CHILD_ID = 'child' 25 | 26 | const parentMachine = createMachine({ 27 | id: 'parent', 28 | initial: 'testing', 29 | invoke: { 30 | id: CHILD_ID, 31 | src: DingDong.machine.withContext(DingDong.initialContext()), 32 | autoForward: true, 33 | }, 34 | states: { 35 | testing: {}, 36 | }, 37 | }) 38 | 39 | const interpreter = interpret(parentMachine) 40 | 41 | const eventList: AnyEventObject[] = [] 42 | interpreter.onTransition(s => { 43 | eventList.push(s.event) 44 | 45 | // console.info('onTransition: ') 46 | // console.info(' - states:', s.value) 47 | // console.info(' - event:', s.event.type) 48 | // console.info() 49 | }) 50 | 51 | interpreter.start() 52 | interpreter.send(DingDong.Event.DING(1)) 53 | t.same( 54 | eventList.map(e => e.type), 55 | [ 56 | 'xstate.init', 57 | Mailbox.Type.ACTOR_IDLE, 58 | DingDong.Type.DING, 59 | ], 60 | 'should have received init/RECEIVE/DING events after initializing', 61 | ) 62 | 63 | eventList.length = 0 64 | await sandbox.clock.runAllAsync() 65 | // eventList.forEach(e => console.info(e)) 66 | t.same( 67 | eventList, 68 | [ 69 | Mailbox.Event.ACTOR_IDLE(), 70 | Mailbox.Event.ACTOR_REPLY(DingDong.Event.DONG(1)), 71 | ], 72 | 'should have received DONG/RECEIVE events after runAllAsync', 73 | ) 74 | 75 | interpreter.stop() 76 | 77 | sandbox.restore() 78 | }) 79 | 80 | test('DingDong.machine process 2+ message at once: only be able to process the first message when receiving multiple events at the same time', async t => { 81 | const sandbox = sinon.createSandbox({ 82 | useFakeTimers: true, 83 | }) 84 | 85 | const containerMachine = createMachine({ 86 | invoke: { 87 | src: DingDong.machine.withContext(DingDong.initialContext()), 88 | autoForward: true, 89 | }, 90 | states: {}, 91 | }) 92 | 93 | const interpreter = interpret( 94 | containerMachine, 95 | ) 96 | 97 | const eventList: AnyEventObject[] = [] 98 | interpreter 99 | .onTransition(s => eventList.push(s.event)) 100 | .start() 101 | 102 | interpreter.send([ 103 | DingDong.Event.DING(0), 104 | DingDong.Event.DING(1), 105 | ]) 106 | 107 | await sandbox.clock.runAllAsync() 108 | eventList.forEach(e => console.info(e)) 109 | t.same( 110 | eventList 111 | .filter(e => e.type === Mailbox.Type.ACTOR_REPLY), 112 | [ 113 | Mailbox.Event.ACTOR_REPLY(DingDong.Event.DONG(0)), 114 | ], 115 | 'should reply DONG to the first DING event', 116 | ) 117 | 118 | interpreter.stop() 119 | sandbox.restore() 120 | }) 121 | -------------------------------------------------------------------------------- /tests/machine-behaviors/ding-dong-machine.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | import { createAction } from 'typesafe-actions' 3 | import { createMachine, actions } from 'xstate' 4 | 5 | import * as Mailbox from '../../src/mods/mod.js' 6 | 7 | enum State { 8 | idle = 'ding-dong/idle', 9 | busy = 'ding-dong/busy', 10 | } 11 | 12 | enum Type { 13 | DING = 'ding-dong/DING', 14 | DONG = 'ding-dong/DONG', 15 | } 16 | 17 | const events = { 18 | DING : createAction(Type.DING, (i: number) => ({ i }))(), 19 | DONG : createAction(Type.DONG, (i: number) => ({ i }))(), 20 | } 21 | 22 | interface Context { 23 | i: number, 24 | } 25 | 26 | const duckula = Mailbox.duckularize({ 27 | id: 'DingDong', 28 | events, 29 | states: State, 30 | initialContext: { i: 0 } as Context, 31 | }) 32 | 33 | const MAX_DELAY_MS = 10 34 | 35 | const machine = createMachine< 36 | Context, 37 | ReturnType 38 | >({ 39 | id: duckula.id, 40 | initial: duckula.State.idle, 41 | context: { 42 | i: -1, 43 | }, 44 | states: { 45 | [duckula.State.idle]: { 46 | entry: [ 47 | Mailbox.actions.idle('DingDongMachine'), 48 | ], 49 | on: { 50 | '*': duckula.State.idle, 51 | [duckula.Type.DING]: { 52 | target: duckula.State.busy, 53 | actions: actions.assign({ 54 | i: (_, e) => e.payload.i, 55 | }), 56 | }, 57 | }, 58 | }, 59 | [duckula.State.busy]: { 60 | after: { 61 | randomMs: { 62 | actions: [ 63 | Mailbox.actions.reply(ctx => events.DONG(ctx.i)), 64 | ], 65 | target: duckula.State.idle, 66 | }, 67 | }, 68 | }, 69 | }, 70 | }, { 71 | delays: { 72 | randomMs: _ => Math.floor(Math.random() * MAX_DELAY_MS), 73 | }, 74 | }) 75 | 76 | duckula.machine = machine 77 | export default duckula as Required 78 | -------------------------------------------------------------------------------- /tests/machine-behaviors/nested-mailbox-machine.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable @typescript-eslint/no-unnecessary-condition */ 3 | /* eslint-disable sort-keys */ 4 | import { 5 | AnyEventObject, 6 | createMachine, 7 | interpret, 8 | } from 'xstate' 9 | import { test } from 'tstest' 10 | import { isActionOf } from 'typesafe-actions' 11 | 12 | import * as Mailbox from '../../src/mods/mod.js' 13 | 14 | import parentMachine, { duckula } from './nested-mailbox-machine.js' 15 | 16 | test('actor smoke testing', async t => { 17 | const TEST_ID = 'TestMachine' 18 | 19 | const wrappedMachine = Mailbox.wrap(parentMachine) 20 | const testMachine = createMachine({ 21 | id: TEST_ID, 22 | invoke: { 23 | id: wrappedMachine.id, 24 | src: wrappedMachine, 25 | }, 26 | initial: 'idle', 27 | states: { 28 | idle: { 29 | on: { 30 | '*': { 31 | actions: Mailbox.actions.proxy(TEST_ID)(wrappedMachine.id), 32 | }, 33 | }, 34 | }, 35 | }, 36 | }) 37 | 38 | const eventList: AnyEventObject[] = [] 39 | const interpreter = interpret(testMachine) 40 | .onEvent(e => { 41 | console.info(TEST_ID, 'EVENT:', e.type, JSON.stringify(e)) 42 | eventList.push(e) 43 | }) 44 | .start() 45 | 46 | eventList.length = 0 47 | 48 | const future = new Promise(resolve => 49 | interpreter.onEvent(e => 50 | isActionOf(duckula.Event.COMPLETE, e) && resolve(e), 51 | ), 52 | ) 53 | 54 | interpreter.send(duckula.Event.NEXT()) 55 | // await new Promise(resolve => setTimeout(resolve, 0)) 56 | await future 57 | 58 | // eventList.forEach(e => console.info(TEST_ID, 'final event list:', e.type, JSON.stringify(e))) 59 | 60 | t.same(eventList, [ 61 | duckula.Event.NEXT(), 62 | duckula.Event.COMPLETE(), 63 | ], 'should get NEXT then COMPLETE events') 64 | 65 | await new Promise(setImmediate) 66 | interpreter.stop() 67 | }) 68 | -------------------------------------------------------------------------------- /tests/machine-behaviors/nested-mailbox-machine.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | import { createMachine, actions } from 'xstate' 3 | import { createAction } from 'typesafe-actions' 4 | 5 | import * as Mailbox from '../../src/mods/mod.js' 6 | 7 | enum State { 8 | Idle = 'nested-mailbox/Idle', 9 | Busy = 'nested-mailbox/Busy', 10 | } 11 | 12 | enum Type { 13 | NEXT = 'nested-mailbox/NEXT', 14 | COMPLETE = 'nested-mailbox/COMPLETE', 15 | } 16 | 17 | const Event = { 18 | NEXT : createAction(Type.NEXT)(), 19 | COMPLETE : createAction(Type.COMPLETE)(), 20 | } as const 21 | 22 | export const duckula = Mailbox.duckularize({ 23 | id: 'Nested', 24 | events: Event, 25 | states: State, 26 | initialContext: {}, 27 | }) 28 | 29 | const CHILD_ID = 'Child' 30 | const childMachine = createMachine({ 31 | id: CHILD_ID, 32 | initial: duckula.State.Idle, 33 | states: { 34 | [duckula.State.Idle]: { 35 | entry: [ 36 | actions.log('states.Idle.entry', CHILD_ID), 37 | Mailbox.actions.idle(CHILD_ID), 38 | ], 39 | on: { 40 | [duckula.Type.NEXT]: { 41 | actions: actions.log('states.Idle.on.NEXT', CHILD_ID), 42 | target: duckula.State.Busy, 43 | }, 44 | }, 45 | }, 46 | 47 | [duckula.State.Busy]: { 48 | entry: [ 49 | actions.log('states.Busy.entry', CHILD_ID), 50 | Mailbox.actions.reply(duckula.Event.COMPLETE()), 51 | ], 52 | always: duckula.State.Idle, 53 | }, 54 | }, 55 | }) 56 | 57 | const PARENT_MACHINE_ID = 'Parent' 58 | const parentMachine = createMachine({ 59 | id: PARENT_MACHINE_ID, 60 | initial: duckula.State.Idle, 61 | states: { 62 | [duckula.State.Idle]: { 63 | entry: [ 64 | actions.log('states.Idle.entry', PARENT_MACHINE_ID), 65 | Mailbox.actions.idle(PARENT_MACHINE_ID), 66 | ], 67 | on: { 68 | [duckula.Type.NEXT]: { 69 | actions: actions.log('states.Idle.on.NEXT', PARENT_MACHINE_ID), 70 | target: duckula.State.Busy, 71 | }, 72 | }, 73 | }, 74 | 75 | [duckula.State.Busy]: { 76 | invoke: { 77 | id: CHILD_ID, 78 | src: Mailbox.wrap(childMachine), 79 | }, 80 | entry: [ 81 | actions.log((_, e) => `states.Busy.entry ${e.type} ${JSON.stringify(e)}`, PARENT_MACHINE_ID), 82 | actions.send((_, e) => e, { to: CHILD_ID }), 83 | ], 84 | on: { 85 | '*': { 86 | actions: actions.log((_, e) => `states.Busy.on ${JSON.stringify(e)}`, PARENT_MACHINE_ID), 87 | target: duckula.State.Idle, 88 | }, 89 | [Mailbox.Type.ACTOR_REPLY]: { 90 | actions: [ 91 | actions.log((_, e) => `states.Busy.on.ACTOR_REPLY ${JSON.stringify(e)}`, PARENT_MACHINE_ID), 92 | actions.send>((_, e) => e.payload.message), 93 | ], 94 | }, 95 | [duckula.Type.COMPLETE]: { 96 | actions: [ 97 | actions.log('states.Busy.on.COMPLETE', PARENT_MACHINE_ID), 98 | Mailbox.actions.reply((_, e) => e), 99 | ], 100 | target: duckula.State.Idle, 101 | }, 102 | }, 103 | }, 104 | 105 | }, 106 | }) 107 | 108 | export default parentMachine 109 | -------------------------------------------------------------------------------- /tests/multiple-outbound-communications.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { test, sinon } from 'tstest' 5 | import { createMachine, actions, interpret } from 'xstate' 6 | 7 | import * as Mailbox from '../src/mods/mod.js' 8 | 9 | test('Mailbox can make outbound communication when it has lots of queued inbound messages', async t => { 10 | const sandbox = sinon.createSandbox({ 11 | useFakeTimers: true, 12 | }) 13 | 14 | const serviceMachine = createMachine<{ count: number }>({ 15 | id: 'service', 16 | context: { 17 | count: 0, 18 | }, 19 | initial: 'idle', 20 | states: { 21 | idle: { 22 | entry: Mailbox.actions.idle('service'), 23 | on: { 24 | DING: 'ding', 25 | '*': 'idle', 26 | }, 27 | }, 28 | ding: { 29 | entry: [ 30 | actions.assign({ count: ctx => ctx.count + 1 }), 31 | ], 32 | after: { 33 | 1000: 'dong', 34 | }, 35 | }, 36 | dong: { 37 | entry: [ 38 | Mailbox.actions.reply(ctx => ({ type: 'DONG', count: ctx.count })), 39 | ], 40 | always: 'idle', 41 | }, 42 | }, 43 | }) 44 | 45 | const serviceMailbox = Mailbox.from(serviceMachine) 46 | serviceMailbox.open() 47 | 48 | const mainMachine = createMachine< 49 | { counts: number[] }, 50 | { type: 'DING' } | { type: 'DONG', count: number } 51 | >({ 52 | id: 'main', 53 | initial: 'idle', 54 | context: { 55 | counts: [], 56 | }, 57 | states: { 58 | idle: { 59 | entry: Mailbox.actions.idle('main'), 60 | on: { 61 | DING: 'ding', 62 | '*': 'idle', 63 | }, 64 | }, 65 | ding: { 66 | entry: [ 67 | serviceMailbox.address.send(({ type: 'DING' })), 68 | ], 69 | on: { 70 | DONG: 'loop', 71 | }, 72 | }, 73 | loop: { 74 | entry: [ 75 | actions.assign({ counts: (ctx, e) => [ ...ctx.counts, (e as any).count ] }), 76 | ], 77 | always: [ 78 | { 79 | cond: ctx => ctx.counts.length < 3, 80 | target: 'ding', 81 | }, 82 | { 83 | target: 'dong', 84 | }, 85 | ], 86 | }, 87 | dong: { 88 | entry: [ 89 | Mailbox.actions.reply(ctx => ({ type: 'DONG', counts: ctx.counts })), 90 | ], 91 | always: 'idle', 92 | }, 93 | }, 94 | }) 95 | 96 | const mailbox = Mailbox.from(mainMachine) 97 | mailbox.open() 98 | 99 | const consumerMachine = createMachine<{ type: 'DONG', counts: number[] }>({ 100 | id: 'consumer', 101 | initial: 'start', 102 | states: { 103 | start: { 104 | entry: [ 105 | mailbox.address.send(({ type: 'DING' })), 106 | ], 107 | on: { 108 | DONG: 'dong', 109 | }, 110 | }, 111 | dong: { 112 | type: 'final', 113 | data: (_, e) => e['log'], 114 | }, 115 | }, 116 | }) 117 | 118 | const eventList: any[] = [] 119 | 120 | const interpreter = interpret(consumerMachine) 121 | .onEvent(e => eventList.push(e)) 122 | .start() 123 | 124 | interpreter.send({ type: 'DING' }) 125 | // ;(mailbox as Mailbox.impls.Mailbox).internal.actor.interpreter?.onTransition(trans => { 126 | // console.info('## Transition for main:', '(' + trans.history?.value + ') + [' + trans.event.type + '] = (' + trans.value + ')') 127 | // }) 128 | // ;(serviceMailbox as Mailbox.impls.Mailbox).internal.actor.interpreter?.onTransition(trans => { 129 | // console.info('## Transition for service:', '(' + trans.history?.value + ') + [' + trans.event.type + '] = (' + trans.value + ')') 130 | // }) 131 | 132 | await sandbox.clock.runAllAsync() 133 | // eventList.forEach(e => console.info(e)) 134 | 135 | t.same(eventList, [ 136 | { type: 'xstate.init' }, 137 | { type: 'DING' }, 138 | { type: 'DONG', counts: [ 1, 2, 3 ] }, 139 | ], 'should get events from all DING events') 140 | 141 | // console.info('Service address', (serviceMailbox as Mailbox.impls.Mailbox).internal.actor.interpreter?.sessionId, '<' + String(serviceMailbox.address) + '>') 142 | // console.info('Main address', (mailbox as Mailbox.impls.Mailbox).internal.actor.interpreter?.sessionId, '<' + String(mailbox.address) + '>') 143 | // console.info('Consumer address', interpreter.sessionId) 144 | 145 | mailbox.close() 146 | sandbox.restore() 147 | }) 148 | -------------------------------------------------------------------------------- /tests/proxy-event-to-mailbox.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { test, sinon } from 'tstest' 5 | import { createMachine, interpret } from 'xstate' 6 | 7 | import * as Mailbox from '../src/mods/mod.js' 8 | 9 | import DingDong from './machine-behaviors/ding-dong-machine.js' 10 | 11 | test('proxy() with Mailbox target', async t => { 12 | const sandbox = sinon.createSandbox({ 13 | useFakeTimers: true, 14 | }) 15 | 16 | const mailbox = Mailbox.from(DingDong.machine.withContext(DingDong.initialContext())) 17 | mailbox.open() 18 | 19 | const testMachine = createMachine({ 20 | on: { 21 | '*': { 22 | actions: Mailbox.actions.proxy('TestMachine')(mailbox), 23 | }, 24 | }, 25 | }) 26 | 27 | const eventList = [] as any[] 28 | const intepretor = interpret(testMachine) 29 | .onEvent(e => eventList.push(e)) 30 | .start() 31 | 32 | intepretor.send(DingDong.Event.DING(1)) 33 | await sandbox.clock.runAllAsync() 34 | 35 | console.info(eventList) 36 | t.same(eventList, [ 37 | { type: 'xstate.init' }, 38 | DingDong.Event.DING(1), 39 | DingDong.Event.DONG(1), 40 | ], 'should get ding/dong events') 41 | 42 | sandbox.restore() 43 | }) 44 | -------------------------------------------------------------------------------- /tests/xstate-behaviors/xstate-actions-order.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { 5 | test, 6 | sinon, 7 | } from 'tstest' 8 | 9 | import { 10 | interpret, 11 | actions, 12 | createMachine, 13 | } from 'xstate' 14 | 15 | const spyActionsMachine = (spy: sinon.SinonSpy) => createMachine<{ lastSetBy: string }>({ 16 | initial: 'step0', 17 | context: { 18 | lastSetBy: 'initializing', 19 | }, 20 | /** 21 | * Issue statelyai/xstate#2891: 22 | * The context provided to the expr inside a State 23 | * should be exactly the **context in this state** 24 | * 25 | * @see https://github.com/statelyai/xstate/issues/2891 26 | */ 27 | preserveActionOrder: true, 28 | 29 | states: { 30 | step0: { 31 | entry: [ 32 | actions.assign({ lastSetBy: _ => { spy('states.step0.entry.assign'); return 'states.step0.entry.assign' } }), 33 | ctx => spy('states.step0.entry.expr context.lastSetBy:' + ctx.lastSetBy), 34 | ], 35 | exit: [ 36 | actions.assign({ lastSetBy: _ => { spy('states.step0.exit.assign'); return 'states.step0.exit.assign' } }), 37 | ctx => spy('states.step0.exit.expr context.lastSetBy:' + ctx.lastSetBy), 38 | ], 39 | on: { 40 | '*': { 41 | target: 'step1', 42 | actions: [ 43 | actions.assign({ lastSetBy: _ => { spy('states.step0.on.assign'); return 'states.step0.on.assign' } }), 44 | ctx => spy('states.step0.on.expr context.lastSetBy:' + ctx.lastSetBy), 45 | ], 46 | }, 47 | }, 48 | }, 49 | step1: { 50 | entry: [ 51 | actions.assign({ lastSetBy: _ => { spy('states.step1.entry.assign'); return 'states.step1.entry.assign' } }), 52 | ctx => spy('states.step1.entry.expr context.lastSetBy:' + ctx.lastSetBy), 53 | ], 54 | always: { 55 | actions: [ 56 | actions.assign({ lastSetBy: _ => { spy('states.step1.always.assign'); return 'states.step1.always.assign' } }), 57 | ctx => spy('states.step1.always.expr context.lastSetBy:' + ctx.lastSetBy), 58 | ], 59 | target: 'step2', 60 | }, 61 | exit: [ 62 | actions.assign({ lastSetBy: _ => { spy('states.step1.exit.assign'); return 'states.step1.exit.assign' } }), 63 | ctx => spy('states.step1.exit.expr context.lastSetBy:' + ctx.lastSetBy), 64 | ], 65 | }, 66 | step2: { 67 | entry: [ 68 | actions.assign({ lastSetBy: _ => { spy('states.step2.entry.assign'); return 'states.step2.entry.assign' } }), 69 | ctx => spy('states.step2.entry.expr context.lastSetBy:' + ctx.lastSetBy), 70 | ], 71 | after: { 72 | 0: { 73 | target: 'step3', 74 | actions: [ 75 | actions.assign({ lastSetBy: _ => { spy('states.step2.after.assign'); return 'states.step2.after.assign' } }), 76 | ctx => spy('states.step2.after.expr context.lastSetBy:' + ctx.lastSetBy), 77 | ], 78 | }, 79 | }, 80 | exit: [ 81 | actions.assign({ lastSetBy: _ => { spy('states.step2.exit.assign'); return 'states.step2.exit.assign' } }), 82 | ctx => spy('states.step2.exit.expr context.lastSetBy:' + ctx.lastSetBy), 83 | ], 84 | }, 85 | step3: { 86 | entry: [ 87 | actions.assign({ lastSetBy: _ => { spy('states.step3.entry.assign'); return 'states.step3.entry.assign' } }), 88 | ctx => spy('states.step3.entry.expr context.lastSetBy:' + ctx.lastSetBy), 89 | ], 90 | type: 'final', 91 | }, 92 | }, 93 | }) 94 | 95 | test('spyActionsMachine actions order testing', async t => { 96 | const sandbox = sinon.createSandbox({ 97 | useFakeTimers: true, 98 | }) 99 | const spy = sandbox.spy() 100 | const machine = spyActionsMachine(spy) 101 | const interpreter = interpret(machine) 102 | 103 | interpreter.onEvent(e => spy('onEvent: received ' + e.type)) 104 | interpreter.onTransition(s => spy('onTransition: transition to ' + s.value)) 105 | 106 | interpreter.start() 107 | 108 | spy('interpreter.send("TEST")') 109 | interpreter.send('TEST') 110 | 111 | await sandbox.clock.runAllAsync() 112 | // console.info(spy.args) 113 | const EXPECTED_ARGS = [ 114 | /** 115 | * Huan(202112): 116 | * 117 | * When receiving a EVENT: 118 | * 1. the actions execute order in transition is: 119 | * 1. exit 120 | * 2. on/always/after 121 | * 3. entry 122 | * 2. all `assign` actions will be ran first, then other actions. 123 | */ 124 | [ 'states.step0.entry.assign' ], 125 | [ 'states.step0.entry.expr context.lastSetBy:states.step0.entry.assign' ], 126 | [ 'onEvent: received xstate.init' ], 127 | [ 'onTransition: transition to step0' ], 128 | [ 'interpreter.send("TEST")' ], 129 | [ 'states.step0.exit.assign' ], 130 | [ 'states.step0.on.assign' ], 131 | [ 'states.step1.entry.assign' ], 132 | [ 'states.step1.exit.assign' ], 133 | [ 'states.step1.always.assign' ], 134 | [ 'states.step2.entry.assign' ], 135 | [ 'states.step0.exit.expr context.lastSetBy:states.step0.exit.assign' ], 136 | [ 'states.step0.on.expr context.lastSetBy:states.step0.on.assign' ], 137 | [ 'states.step1.entry.expr context.lastSetBy:states.step1.entry.assign' ], 138 | [ 'states.step1.exit.expr context.lastSetBy:states.step1.exit.assign' ], 139 | [ 'states.step1.always.expr context.lastSetBy:states.step1.always.assign' ], 140 | [ 'states.step2.entry.expr context.lastSetBy:states.step2.entry.assign' ], 141 | [ 'onEvent: received TEST' ], 142 | [ 'onTransition: transition to step2' ], 143 | [ 'states.step2.exit.assign' ], 144 | [ 'states.step2.after.assign' ], 145 | [ 'states.step3.entry.assign' ], 146 | [ 'states.step2.exit.expr context.lastSetBy:states.step2.exit.assign' ], 147 | [ 'states.step2.after.expr context.lastSetBy:states.step2.after.assign' ], 148 | [ 'states.step3.entry.expr context.lastSetBy:states.step3.entry.assign' ], 149 | [ 'onEvent: received xstate.after(0)#(machine).step2' ], 150 | [ 'onTransition: transition to step3' ], 151 | ] 152 | 153 | t.same(spy.args, EXPECTED_ARGS, 'should get the same order as expected') 154 | 155 | interpreter.stop() 156 | sandbox.restore() 157 | }) 158 | -------------------------------------------------------------------------------- /tests/xstate-behaviors/xstate-child-exit-order.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { 5 | test, 6 | sinon, 7 | } from 'tstest' 8 | 9 | import { 10 | interpret, 11 | createMachine, 12 | spawn, 13 | actions, 14 | ActorRef, 15 | } from 'xstate' 16 | 17 | test('xstate machine with spawn-ed & invoked child machine order testing for entry/exit orders', async t => { 18 | const sandbox = sinon.createSandbox() 19 | const spy = sandbox.spy() 20 | 21 | const childMachine = (id: string) => createMachine({ 22 | entry: () => spy(id + ' childMachine.entry'), 23 | exit: () => spy(id + ' childMachine.exit'), 24 | }) 25 | 26 | interface Context { 27 | childRef?: ActorRef 28 | } 29 | 30 | const parentMachine = createMachine({ 31 | entry: [ 32 | _ => spy('parentMachine.entry'), 33 | actions.assign({ 34 | childRef: _ => spawn(childMachine('spawn')), 35 | }), 36 | ], 37 | exit: [ 38 | _ => spy('parentMachine.exit'), 39 | ], 40 | context: { 41 | childRef: undefined, 42 | }, 43 | invoke: { 44 | src: childMachine('invoke'), 45 | }, 46 | }) 47 | 48 | const interpreter = interpret(parentMachine) 49 | 50 | spy.resetHistory() 51 | interpreter.start() 52 | t.same(spy.args.map(a => a[0]), [ 53 | 'spawn childMachine.entry', 54 | 'invoke childMachine.entry', 55 | 'parentMachine.entry', 56 | ], 'should call entry actions in order') 57 | 58 | spy.resetHistory() 59 | interpreter.stop() 60 | t.same(spy.args.map(a => a[0]), [ 61 | 'parentMachine.exit', 62 | 'spawn childMachine.exit', 63 | 'invoke childMachine.exit', 64 | ], 'should call exit actions in order') 65 | 66 | sandbox.restore() 67 | }) 68 | -------------------------------------------------------------------------------- /tests/xstate-behaviors/xstate-interpreter-children.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { test } from 'tstest' 5 | import { interpret, createMachine } from 'xstate' 6 | 7 | test('XState parent machine should have child machine in services', async t => { 8 | const CHILD_ID = 'child-id' 9 | 10 | const childMachine = createMachine({}) 11 | const parentMachine = createMachine({ 12 | invoke: { 13 | id: CHILD_ID, 14 | src: childMachine, 15 | }, 16 | }) 17 | const interpreter = interpret(parentMachine) 18 | 19 | t.ok(interpreter.sessionId, 'should get a vaild sessionId') 20 | 21 | const services = parentMachine.options.services || {} 22 | 23 | t.equal(services[CHILD_ID], childMachine, 'should have child machine in services') 24 | // console.info(interpreter) 25 | }) 26 | -------------------------------------------------------------------------------- /tests/xstate-behaviors/xstate-interpreter-session-id.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { test } from 'tstest' 5 | import { interpret, createMachine } from 'xstate' 6 | 7 | test('XState interpreter should have a consistent sessionId between start/stop', async t => { 8 | const interpreter = interpret(createMachine({})) 9 | 10 | const sessionId = interpreter.sessionId 11 | t.ok(sessionId, 'should get a vaild sessionId') 12 | 13 | interpreter.start() 14 | t.equal(interpreter.sessionId, sessionId, 'should be consistent sessionId after start') 15 | 16 | interpreter.stop() 17 | t.equal(interpreter.sessionId, sessionId, 'should be consistent sessionId after stop') 18 | 19 | interpreter.start() 20 | t.equal(interpreter.sessionId, sessionId, 'should be consistent sessionId after restart') 21 | }) 22 | -------------------------------------------------------------------------------- /tests/xstate-behaviors/xstate-send-address.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { 5 | test, 6 | } from 'tstest' 7 | 8 | import { 9 | interpret, 10 | createMachine, 11 | actions, 12 | AnyEventObject, 13 | } from 'xstate' 14 | 15 | test('two xstate machine interpreter can exchange events', async t => { 16 | const testMachine = createMachine({ 17 | on: { 18 | TEST: { 19 | actions: [ 20 | actions.send('OK', { to: (_, e) => (e as any).address }), 21 | ], 22 | }, 23 | }, 24 | }) 25 | 26 | const interpreterA = interpret(testMachine) 27 | const interpreterB = interpret(testMachine) 28 | 29 | const eventListB: AnyEventObject[] = [] 30 | interpreterB 31 | .onEvent(e => eventListB.push(e)) 32 | .start() 33 | 34 | const addressB = interpreterB.sessionId 35 | 36 | interpreterA.start() 37 | interpreterA.send('TEST', { address: addressB }) 38 | 39 | t.same( 40 | eventListB.filter(e => e.type === 'OK'), 41 | [ { type: 'OK' } ], 42 | 'should receive OK event by B sent from A') 43 | }) 44 | -------------------------------------------------------------------------------- /tests/xstate-behaviors/xstate-state-can.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable sort-keys */ 3 | 4 | import { 5 | test, 6 | } from 'tstest' 7 | 8 | import { 9 | interpret, 10 | createMachine, 11 | actions, 12 | } from 'xstate' 13 | 14 | test('state.can() machine with a TEST event', async t => { 15 | const testMachine = createMachine({ 16 | on: { 17 | TEST: { 18 | actions: actions.log('EVENT:TEST'), 19 | }, 20 | }, 21 | }) 22 | 23 | const interpreter = interpret(testMachine) 24 | 25 | interpreter.start() 26 | const snapshot = interpreter.getSnapshot() 27 | 28 | t.ok(snapshot.can('TEST'), 'should be able to send event TEST') 29 | t.notOk(snapshot.can('XXX'), 'should not be able to send event XXX') 30 | }) 31 | 32 | test('state.can() machine with a * event', async t => { 33 | const testMachine = createMachine({ 34 | on: { 35 | '*': { 36 | actions: actions.log('EVENT:*'), 37 | }, 38 | }, 39 | }) 40 | 41 | const interpreter = interpret(testMachine) 42 | 43 | interpreter.start() 44 | const snapshot = interpreter.getSnapshot() 45 | 46 | t.ok(snapshot.can('TEST'), 'should be able to send event TESST') 47 | t.ok(snapshot.can('XXX'), 'should be able to send event XXX') 48 | }) 49 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "outDir": "dist/cjs", 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@chatie/tsconfig", 3 | "compilerOptions": { 4 | "outDir": "dist/esm", 5 | }, 6 | "exclude": [ 7 | "node_modules/", 8 | "dist/", 9 | "tests/fixtures/", 10 | ], 11 | "include": [ 12 | "bin/*.ts", 13 | "examples/**/*.ts", 14 | "scripts/**/*.ts", 15 | "src/**/*.ts", 16 | "tests/**/*.spec.ts", 17 | "tests/machine-behaviors/**/.ts", 18 | ], 19 | } 20 | --------------------------------------------------------------------------------