|
| 1 | +## PureMVC MultiCore tate Machine Utility — Developer Guide |
| 2 | + |
| 3 | +This guide explains how the Finite State Machine (FSM) utility works and how to use it with PureMVC (MultiCore) in TypeScript. It includes practical examples showing both programmatic setup and JSON-driven configuration via `FSMInjector`. |
| 4 | + |
| 5 | +### What you get |
| 6 | +- A lightweight `StateMachine` `Mediator` that manages named `State` objects |
| 7 | +- Declarative transitions between states triggered by simple action strings |
| 8 | +- Optional notifications fired when entering, exiting, or after changing state |
| 9 | +- A `FSMInjector` that builds and registers the `StateMachine` from a JSON config |
| 10 | + |
| 11 | +### Install |
| 12 | +``` |
| 13 | +npm install @puremvc/puremvc-typescript-multicore-framework |
| 14 | +npm install @puremvc/puremvc-typescript-util-state-machine |
| 15 | +``` |
| 16 | + |
| 17 | +### Imports (ESM) |
| 18 | +```ts |
| 19 | +import { |
| 20 | + StateMachine, |
| 21 | + FSMInjector, |
| 22 | + State, |
| 23 | + type FSM, |
| 24 | +} from '@puremvc/puremvc-typescript-util-state-machine'; |
| 25 | +``` |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +### Core concepts |
| 30 | + |
| 31 | +- StateMachine (Mediator) |
| 32 | + - Name: `StateMachine.NAME` (`"StateMachine"`) |
| 33 | + - Notifications it cares about: |
| 34 | + - `StateMachine.ACTION` — trigger transitions (type = action string; body = optional data) |
| 35 | + - `StateMachine.CANCEL` — cancel a pending transition during exiting/entering |
| 36 | + - Notification it emits: |
| 37 | + - `StateMachine.CHANGED` — broadcast after a successful transition; body = new `State`; type = state name |
| 38 | + - Holds a registry of `State` instances and tracks `currentState`. |
| 39 | + |
| 40 | +- State |
| 41 | + - Properties: `name`, optional `entering`, `exiting`, `changed` notification names |
| 42 | + - Methods: |
| 43 | + - `defineTransition(action: string, target: string)` — define an action that leads to another state |
| 44 | + - `getTarget(action: string)` — resolve the target state name for an action |
| 45 | + |
| 46 | +- FSM JSON schema (for `FSMInjector`) |
| 47 | + - Types exported as `FSM`, `StateDef`, `Transition` |
| 48 | + - Shape: |
| 49 | + ```ts |
| 50 | + type Transition = { action: string; target: string }; |
| 51 | + type StateDef = { |
| 52 | + name: string; |
| 53 | + entering?: string; // notification to send on entering |
| 54 | + exiting?: string; // notification to send on exiting |
| 55 | + changed?: string; // notification to send after state becomes current |
| 56 | + transitions?: Transition[]; |
| 57 | + }; |
| 58 | + type FSM = { initial: string; states: StateDef[] }; |
| 59 | + ``` |
| 60 | + |
| 61 | +--- |
| 62 | + |
| 63 | +### Transition lifecycle |
| 64 | + |
| 65 | +When an actor sends `StateMachine.ACTION` with the desired action in the notification type, the `StateMachine` checks the current state for a matching transition: |
| 66 | + |
| 67 | +1. If found, an exit sequence starts: |
| 68 | + - If the current state has `exiting`, it sends that notification with: |
| 69 | + - body = any `data` passed in the ACTION |
| 70 | + - type = next state name (so observers know where we’re going) |
| 71 | + - Any observer can cancel the transition by sending `StateMachine.CANCEL` while handling the exit notification. |
| 72 | +2. If not canceled, an enter sequence starts for the next state: |
| 73 | + - If the next state has `entering`, it sends that notification with body = the same `data`. |
| 74 | + - Observers may also cancel here by sending `StateMachine.CANCEL`. |
| 75 | +3. If not canceled: |
| 76 | + - `currentState` becomes the next state. |
| 77 | + - If the state has `changed`, it sends that notification with body = the same `data`. |
| 78 | + - Then `StateMachine.CHANGED` is sent with body = the new `State` and type = its name. |
| 79 | + |
| 80 | +Notes: |
| 81 | +- Cancelation resets the internal flag and the `currentState` remains unchanged. |
| 82 | +- The `StateMachine` is a `Mediator`; it should be registered with your `Facade`. |
| 83 | +- On registration (`onRegister`), if an initial state is set, it transitions to it immediately (running the enter/changed notifications for that state only). |
| 84 | + |
| 85 | +--- |
| 86 | + |
| 87 | +### Programmatic setup example |
| 88 | + |
| 89 | +This minimal example shows how to create states, define transitions, register the `StateMachine`, and fire actions. |
| 90 | + |
| 91 | +```ts |
| 92 | +import { |
| 93 | + Facade, |
| 94 | + Notifier, |
| 95 | + INotification, |
| 96 | + Mediator, |
| 97 | +} from '@puremvc/puremvc-typescript-multicore-framework'; |
| 98 | +import { StateMachine, State } from '@puremvc/puremvc-typescript-util-state-machine'; |
| 99 | +
|
| 100 | +const MULTITON_KEY = 'com.example.app'; |
| 101 | +
|
| 102 | +// 1) Build your states |
| 103 | +const opened = new State( |
| 104 | + 'OPENED', |
| 105 | + 'note/opening', // entering |
| 106 | + null, // exiting |
| 107 | + 'note/opened', // changed |
| 108 | +); |
| 109 | +const closed = new State( |
| 110 | + 'CLOSED', |
| 111 | + 'note/closing', // entering |
| 112 | +); |
| 113 | +
|
| 114 | +// 2) Define transitions |
| 115 | +opened.defineTransition('CLOSE', 'CLOSED'); |
| 116 | +closed.defineTransition('OPEN', 'OPENED'); |
| 117 | +
|
| 118 | +// 3) Create and register the StateMachine |
| 119 | +const fsm = new StateMachine(); |
| 120 | +fsm.initializeNotifier(MULTITON_KEY); |
| 121 | +fsm.registerState(opened /* initial? */); |
| 122 | +fsm.registerState(closed, /* initial = */ true); |
| 123 | +
|
| 124 | +Facade.getInstance(MULTITON_KEY).registerMediator(fsm as Mediator); |
| 125 | +
|
| 126 | +// 4) Listen for changes and lifecycle notifications elsewhere |
| 127 | +class DoorMediator extends Mediator { |
| 128 | + public static NAME = 'DoorMediator'; |
| 129 | + constructor() { super(DoorMediator.NAME); } |
| 130 | +
|
| 131 | + listNotificationInterests(): string[] { |
| 132 | + return [ |
| 133 | + 'note/opening', |
| 134 | + 'note/opened', |
| 135 | + 'note/closing', |
| 136 | + StateMachine.CHANGED, |
| 137 | + ]; |
| 138 | + } |
| 139 | +
|
| 140 | + handleNotification(note: INotification): void { |
| 141 | + switch (note.name) { |
| 142 | + case 'note/opening': |
| 143 | + console.log('Entering OPENED with data:', note.body); |
| 144 | + break; |
| 145 | + case 'note/opened': |
| 146 | + console.log('Changed to OPENED'); |
| 147 | + break; |
| 148 | + case 'note/closing': |
| 149 | + console.log('Entering CLOSED with data:', note.body); |
| 150 | + break; |
| 151 | + case StateMachine.CHANGED: |
| 152 | + // note.body is the new State |
| 153 | + console.log('FSM changed to:', (note.body as State).name); |
| 154 | + break; |
| 155 | + } |
| 156 | + } |
| 157 | +} |
| 158 | +
|
| 159 | +const facade = Facade.getInstance(MULTITON_KEY); |
| 160 | +facade.registerMediator(new DoorMediator()); |
| 161 | +
|
| 162 | +// 5) Trigger transitions from any Notifier-aware actor |
| 163 | +class UserAction extends Notifier { |
| 164 | + constructor() { super(); this.initializeNotifier(MULTITON_KEY); } |
| 165 | + openDoor() { |
| 166 | + this.sendNotification(StateMachine.ACTION, { by: 'user' }, 'OPEN'); |
| 167 | + } |
| 168 | + closeDoor() { |
| 169 | + this.sendNotification(StateMachine.ACTION, { by: 'user' }, 'CLOSE'); |
| 170 | + } |
| 171 | +} |
| 172 | +
|
| 173 | +const user = new UserAction(); |
| 174 | +user.openDoor(); // CLOSED -> OPENED |
| 175 | +user.closeDoor(); // OPENED -> CLOSED |
| 176 | +``` |
| 177 | + |
| 178 | +Tip: The action string is passed in the notification `type`. The optional `body` payload flows through exiting/entering/changed notifications. |
| 179 | + |
| 180 | +--- |
| 181 | + |
| 182 | +### Canceling a transition |
| 183 | + |
| 184 | +Any observer of an `exiting` or `entering` notification can veto the move by sending `StateMachine.CANCEL` before the sequence completes. |
| 185 | + |
| 186 | +```ts |
| 187 | +class GuardMediator extends Mediator { |
| 188 | + public static NAME = 'GuardMediator'; |
| 189 | + constructor() { super(GuardMediator.NAME); } |
| 190 | +
|
| 191 | + listNotificationInterests(): string[] { |
| 192 | + return ['note/unlocking']; // suppose this is an exiting/entering note |
| 193 | + } |
| 194 | +
|
| 195 | + handleNotification(note: INotification): void { |
| 196 | + const shouldBlock = /* your rule */ false; |
| 197 | + if (shouldBlock) { |
| 198 | + this.facade.sendNotification(StateMachine.CANCEL); |
| 199 | + } |
| 200 | + } |
| 201 | +} |
| 202 | +``` |
| 203 | + |
| 204 | +When canceled, the FSM remains in the current state and no `changed` or `StateMachine.CHANGED` notifications are sent. |
| 205 | + |
| 206 | +--- |
| 207 | + |
| 208 | +### JSON-driven setup with FSMInjector |
| 209 | + |
| 210 | +Use `FSMInjector` when you want to drive the FSM from configuration rather than code. |
| 211 | + |
| 212 | +```ts |
| 213 | +import { |
| 214 | + Facade, |
| 215 | +} from '@puremvc/puremvc-typescript-multicore-framework'; |
| 216 | +import { |
| 217 | + FSMInjector, |
| 218 | + StateMachine, |
| 219 | + type FSM, |
| 220 | +} from '@puremvc/puremvc-typescript-util-state-machine'; |
| 221 | +
|
| 222 | +const MULTITON_KEY = 'com.example.app'; |
| 223 | +
|
| 224 | +const fsmConfig: FSM = { |
| 225 | + initial: 'CLOSED', |
| 226 | + states: [ |
| 227 | + { |
| 228 | + name: 'OPENED', |
| 229 | + entering: 'note/opening', |
| 230 | + changed: 'note/opened', |
| 231 | + transitions: [ |
| 232 | + { action: 'CLOSE', target: 'CLOSED' }, |
| 233 | + ], |
| 234 | + }, |
| 235 | + { |
| 236 | + name: 'CLOSED', |
| 237 | + entering: 'note/closing', |
| 238 | + transitions: [ |
| 239 | + { action: 'OPEN', target: 'OPENED' }, |
| 240 | + { action: 'LOCK', target: 'LOCKED' }, |
| 241 | + ], |
| 242 | + }, |
| 243 | + { |
| 244 | + name: 'LOCKED', |
| 245 | + entering: 'note/locking', |
| 246 | + exiting: 'note/unlocking', |
| 247 | + transitions: [ |
| 248 | + { action: 'UNLOCK', target: 'CLOSED' }, |
| 249 | + ], |
| 250 | + }, |
| 251 | + ], |
| 252 | +}; |
| 253 | +
|
| 254 | +// Build + register the StateMachine from JSON |
| 255 | +const injector = new FSMInjector(MULTITON_KEY, fsmConfig); |
| 256 | +const fsm = injector.inject(); // returns the created & registered StateMachine |
| 257 | +
|
| 258 | +// Listen for global CHANGED notifications |
| 259 | +Facade.getInstance(MULTITON_KEY).registerMediator(new (class extends Mediator { |
| 260 | + constructor() { super('LogFSM'); } |
| 261 | + listNotificationInterests() { return [StateMachine.CHANGED]; } |
| 262 | + handleNotification(note: INotification) { |
| 263 | + console.log('FSM CHANGED ->', note.type); // state name |
| 264 | + } |
| 265 | +})()); |
| 266 | +
|
| 267 | +// Fire actions as before |
| 268 | +Facade.getInstance(MULTITON_KEY).sendNotification(StateMachine.ACTION, undefined, 'OPEN'); |
| 269 | +``` |
| 270 | + |
| 271 | +How it works under the hood: |
| 272 | +- `FSMInjector.inject()` creates a new `StateMachine`, initializes it with your Multiton key, builds `State` instances from each `StateDef`, registers initial state, and registers the `StateMachine` with your `Facade`. |
| 273 | +- On `onRegister()`, the `StateMachine` automatically transitions to the initial state if one was provided. |
| 274 | + |
| 275 | +--- |
| 276 | + |
| 277 | +### Practical tips |
| 278 | + |
| 279 | +- Keep action strings short and consistent (e.g., `OPEN`, `CLOSE`). Consider centralizing them in a `const` enum or constants module. |
| 280 | +- Use the `exiting` notification’s type (which the FSM sets to the target state name) to detect where you’re headed, useful for preloading or validation. |
| 281 | +- Avoid side effects in `CHANGED` handlers that could recursively trigger more actions unless you explicitly want chained transitions. |
| 282 | +- If a transition is often canceled, consider checking preconditions before sending `StateMachine.ACTION` to reduce churn. |
| 283 | +- Unit tests: mock observers can assert the exact order of notifications: exiting -> entering -> changed -> StateMachine.CHANGED. |
| 284 | + |
| 285 | +--- |
| 286 | + |
| 287 | +### Troubleshooting |
| 288 | + |
| 289 | +- Nothing happens when you send `StateMachine.ACTION`: |
| 290 | + - Ensure the `StateMachine` is registered with the same Multiton key as the sender. |
| 291 | + - Ensure the current state defines a transition for the given action string. |
| 292 | + - Verify you passed the action in `type`, not `body`. |
| 293 | + |
| 294 | +- Transition always aborts: |
| 295 | + - Some observer may be sending `StateMachine.CANCEL` while handling `exiting` or `entering`. Add logging to those handlers to find the culprit. |
| 296 | + |
| 297 | +- Didn’t enter the initial state: |
| 298 | + - The initial state is set by passing `initial=true` to `registerState(...)` or via the JSON `initial` property. |
| 299 | + - Confirm `onRegister()` is being called (i.e., the `StateMachine` is actually registered as a Mediator). |
| 300 | + |
| 301 | +--- |
| 302 | + |
| 303 | +### API quick reference |
| 304 | + |
| 305 | +- StateMachine |
| 306 | + - Constants: `NAME`, `ACTION`, `CHANGED`, `CANCEL` |
| 307 | + - Methods: |
| 308 | + - `registerState(state: State, initial?: boolean): void` |
| 309 | + - `getState(name: string): State | undefined` |
| 310 | + - `removeState(name: string): void` |
| 311 | + - `viewComponent: State | null` — current state |
| 312 | + |
| 313 | +- State |
| 314 | + - Constructor: `new State(name, entering?, exiting?, changed?)` |
| 315 | + - `defineTransition(action, target)` |
| 316 | + - `getTarget(action)` |
| 317 | + |
| 318 | +- FSMInjector |
| 319 | + - Constructor: `new FSMInjector(multitonKey: string, fsm: FSM)` |
| 320 | + - `inject(): StateMachine` — creates states, registers the `StateMachine` with the Facade |
| 321 | + |
| 322 | +--- |
| 323 | + |
| 324 | +If you need deeper details, see the source in `src/fsm` and the type definitions in `src/types.ts`. |
0 commit comments