Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,9 @@ fluentState

## Further Reading

Fluent State is a non-hierarchial state machine. For more information on its architecture and how it operates, please refer to the [State Machine](./state-machine.md) documentation.
- Fluent State is a non-hierarchial state machine. For more information on its architecture and how it operates, please refer to the [State Machine](./docs/state-machine.md) documentation.
- Fluent State has a flexible plugin architecture. See the [Plugins](./docs/plugins.md) documentation for more details.

## Contributing 🤝

So you want to contribute to the Fluent State project? Fantastic! Please read the [Contribute](./contribute.md) doc to get started.
So you want to contribute to the Fluent State project? Fantastic! Please read the [Contribute](./docs/contribute.md) doc to get started.
File renamed without changes.
65 changes: 65 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Plugin Architecture for FluentState

## Overview
`FluentState` supports a flexible plugin architecture that allows for extending its functionality through plugins. Plugins can be functions or objects that enhance the state machine's capabilities.

## Plugin Types
- **Function Plugins**: Functions that either extend the `FluentState` instance or act as middleware for state transitions.
- **Object Plugins**: Objects with an `install` method that is called with the `FluentState` instance.

## Middleware
Middleware functions are a special type of plugin that intercept state transitions. They have three parameters: the previous state, the next state, and a transition function. Middleware can block transitions by not calling the transition function.

## Installation
Plugins are installed using the `use` method of the `FluentState` class. The `use` method checks the type of the plugin and either adds it to the middleware list or calls its `install` method. The method returns the `FluentState` instance, allowing for method chaining.

## Example Usage

- **Function Plugin**:
```typescript
fluentState.use((fluentState) => {
// Extend the fluentState instance
});
```

- **Middleware Plugin**:
```typescript
fluentState.use((prev, next, transition) => {
// Intercept transition
if (someCondition) {
transition(); // Allow transition
}
});
```

- **Object Plugin**:
```typescript
const plugin = {
install(fluentState) {
// Extend the fluentState instance
}
};
fluentState.use(plugin);
```

## Native Plugins

### Transition Guard

- **Name**: Transition Guard
- **Purpose**: Allows intercepting and controlling state transitions by providing a middleware function.
- **Usage**:
- The plugin is created using the `createTransitionGuard` function, which takes a handler function as an argument.
- The handler function receives the current state, the next state name, and a `proceed` function. The `proceed` function must be called to allow the transition to continue.
- **Example**:
```typescript
fluentState.use(createTransitionGuard((prev, next, proceed) => {
console.log(`Checking transition: ${prev?.name} → ${next}`);
if (next === 'mainApp' && !userHasAccess()) {
console.log("Access Denied!");
return;
}
proceed();
}));
```

File renamed without changes.
138 changes: 92 additions & 46 deletions src/fluent-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,42 @@ import { Event } from "./event";
import { Observer } from "./observer";
import { Lifecycle } from "./enums";
import { LifeCycleHandler } from "./types";
import { FluentStatePlugin } from "./types";
import { TransitionError, StateError } from "./types";

export class FluentState {
states: Map<string, State> = new Map();
readonly states: Map<string, State> = new Map();

state: State;

observer: Observer = new Observer();
readonly observer: Observer = new Observer();

private middlewares: ((prev: State | null, next: string, transition: () => void) => void)[] = [];

/**
* Extends the state machine with a plugin.
* A plugin can be:
* 1. A function that takes the FluentState instance and extends it
* 2. A transition middleware function that intercepts transitions
* 3. An object with an install method
*
* @param plugin - The plugin to install
* @returns The FluentState instance for chaining
*/
use(plugin: FluentStatePlugin): FluentState {
if (typeof plugin === "function") {
// Check if it's a middleware function (3 parameters) or a plugin function (1 parameter)
if (plugin.length === 3) {
this.middlewares.push(plugin as (prev: State | null, next: string, transition: () => void) => void);
} else {
(plugin as (fluentState: FluentState) => void)(this);
}
} else {
// It's a plugin object with an install method
plugin.install(this);
}
return this;
}

from(name: string): State {
let state = this._getState(name);
Expand Down Expand Up @@ -37,7 +66,7 @@ export class FluentState {
}

can(name: string): boolean {
return this.state.can(name);
return this.state && this.state.can(name);
}

/**
Expand All @@ -46,59 +75,21 @@ export class FluentState {
* Lifecycle events within the transition process occur in this order:
* BeforeTransition -> FailedTransition -> AfterTransition -> State-specific handlers
*
* This order ensures that:
* 1. Pre-transition checks can be performed (BeforeTransition)
* 2. Failed transitions are properly handled (FailedTransition)
* 3. Post-transition logic is executed (AfterTransition)
* 4. State-specific actions are performed last
*
* This separation provides a clear distinction between the transition process itself
* and any side effects that should occur after entering a new state.
*
* @param names - The name(s) of the state(s) to transition to. If multiple are provided, one is chosen randomly.
* @returns true if the transition was successful, false otherwise.
*/
transition(...names: string[]): boolean {
if (!names.length) {
throw new Error("Transition error: No target state specified");
throw new TransitionError(`No target state specified. Available states: ${Array.from(this.states.keys()).join(", ")}`);
}

const currentState = this.state;
const nextStateName = names.length === 1 ? names[0] : names[Math.floor(Math.random() * names.length)];

// BeforeTransition must occur first to allow for any pre-transition logic or validation,
// and to provide an opportunity to cancel the transition if necessary.
const results = this.observer.trigger(Lifecycle.BeforeTransition, currentState, nextStateName);
if (results.includes(false)) {
return false;
}

// FailedTransition must occur next to allow for any failed transition logic, including whether
// the transition has been cancelled.
if (!this.can(nextStateName)) {
this.observer.trigger(Lifecycle.FailedTransition, currentState, nextStateName);
if (!this._runMiddlewares(currentState, nextStateName)) {
return false;
}

const nextState = this._getState(nextStateName);

// Trigger exit hook before state change
currentState._triggerExit(nextState);

this.setState(nextStateName);

// Trigger enter hook after state change but before AfterTransition
nextState._triggerEnter(currentState);

// AfterTransition is triggered after the state has changed but before any state-specific handlers.
// This allows for any general post-transition logic.
this.observer.trigger(Lifecycle.AfterTransition, currentState, nextState);

// State-specific handlers are executed last. These are defined using `when().do()` and
// are meant for actions that should occur specifically after entering this new state.
this.state.handlers.forEach((handler) => handler(currentState, nextState));

return true;
return this._executeTransition(currentState, nextStateName);
}

next(...exclude: string[]): boolean {
Expand All @@ -109,7 +100,7 @@ export class FluentState {
when(name: string): Event {
const state = this._getState(name);
if (!state) {
throw new Error(`When error: Unknown state: "${name}"`);
throw new StateError(`Unknown state: "${name}". Available states: ${Array.from(this.states.keys()).join(", ")}`);
}

return new Event(state);
Expand Down Expand Up @@ -150,7 +141,7 @@ export class FluentState {
setState(name: string): State {
const state = this._getState(name);
if (!state) {
throw new Error(`SetState Error: Unknown state: "${name}"`);
throw new StateError(`Unknown state: "${name}". Available states: ${Array.from(this.states.keys()).join(", ")}`);
}

this.state = state;
Expand All @@ -171,6 +162,61 @@ export class FluentState {
_getState(name: string): State {
return this.states.get(name);
}

private _runMiddlewares(currentState: State, nextStateName: string): boolean {
let index = 0;
let shouldProceed = false;

const runNextMiddleware = () => {
shouldProceed = true;
};

while (index < this.middlewares.length) {
shouldProceed = false;
const middleware = this.middlewares[index++];
middleware(currentState, nextStateName, runNextMiddleware);
if (!shouldProceed) {
return false; // Middleware blocked the transition
}
}
return true;
}

private _executeTransition(currentState: State, nextStateName: string): boolean {
// BeforeTransition must occur first to allow for any pre-transition logic or validation,
// and to provide an opportunity to cancel the transition if necessary.
const results = this.observer.trigger(Lifecycle.BeforeTransition, currentState, nextStateName);
if (results.includes(false)) {
return false;
}

// FailedTransition must occur next to allow for any failed transition logic, including whether
// the transition has been cancelled.
if (!this.can(nextStateName)) {
this.observer.trigger(Lifecycle.FailedTransition, currentState, nextStateName);
return false;
}

const nextState = this._getState(nextStateName);

// Trigger exit hook before state change
currentState._triggerExit(nextState);

this.setState(nextStateName);

// Trigger enter hook after state change but before AfterTransition
nextState._triggerEnter(currentState);

// AfterTransition is triggered after the state has changed but before any state-specific handlers.
// This allows for any general post-transition logic.
this.observer.trigger(Lifecycle.AfterTransition, currentState, nextState);

// State-specific handlers are executed last. These are defined using `when().do()` and
// are meant for actions that should occur specifically after entering this new state.
this.state.handlers.forEach((handler) => handler(currentState, nextState));

return true;
}
}

export const fluentState = new FluentState();
Expand Down
10 changes: 10 additions & 0 deletions src/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ export class Observer {
return handlers.map((handler) => executor.execute(handler, prevState, currentState));
}

remove(event: Lifecycle, handler: LifeCycleHandler): void {
const handlers = this.getEvent(event);
if (handlers) {
this.observers.set(
event,
handlers.filter((h) => h !== handler),
);
}
}

private getEvent(name: Lifecycle): LifeCycleHandler[] | undefined {
return this.observers.get(name);
}
Expand Down
1 change: 1 addition & 0 deletions src/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./transition-guard";
44 changes: 44 additions & 0 deletions src/plugins/transition-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { FluentState } from "../fluent-state";
import { Lifecycle } from "../enums";
import { FluentStatePlugin } from "../types";
import { State } from "../state";

export type TransitionMiddlewareHandler = (currentState: State | null, nextStateName: string, proceed: () => void) => void;

/**
* Creates a transition guard plugin that allows intercepting and controlling state transitions.
*
* @example
* ```typescript
* // Create and use the plugin
* fluentState.use(createTransitionGuard((prev, next, proceed) => {
* console.log(`Checking transition: ${prev?.name} → ${next}`);
* if (next === 'mainApp' && !userHasAccess()) {
* console.log("Access Denied!");
* return;
* }
* proceed();
* }));
* ```
*
* @param handler - A function that receives the current state, the next state name, and a proceed function.
* The proceed function must be called to allow the transition to continue.
* @returns A FluentStatePlugin that can be used with the FluentState instance.
*/
export function createTransitionGuard(handler: TransitionMiddlewareHandler): FluentStatePlugin {
return (fluentState: FluentState) => {
fluentState.observe(Lifecycle.BeforeTransition, (currentState, nextStateName) => {
let proceeded = false;

try {
handler(currentState, nextStateName, () => {
proceeded = true;
});
} catch (error) {
console.error("Error in transition guard:", error);
}

return proceeded;
});
};
}
26 changes: 26 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { State } from "./state";
import { FluentState } from "./fluent-state";

export type BeforeTransitionHandler = (currentState: State, nextState: string) => boolean;
export type FailedTransitionHandler = (currentState: State, targetState: string) => void;
Expand All @@ -10,3 +11,28 @@ export type EventHandler = (previousState: State, currentState: State) => void;

export type EnterEventHandler = (previousState: State, currentState: State) => void;
export type ExitEventHandler = (currentState: State, nextState: State) => void;

/**
* A plugin can be either:
* 1. A function that takes the FluentState instance and extends it
* 2. A transition middleware function that intercepts transitions
* 3. An object that implements the Plugin interface
*/
export type FluentStatePlugin =
| ((fluentState: FluentState) => void)
| ((prev: State | null, next: string, transition: () => void) => void)
| { install: (fluentState: FluentState) => void };

export class TransitionError extends Error {
constructor(message: string) {
super(message);
this.name = "TransitionError";
}
}

export class StateError extends Error {
constructor(message: string) {
super(message);
this.name = "StateError";
}
}
3 changes: 2 additions & 1 deletion tests/error-handling.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect } from "chai";
import { FluentState } from "../src";
import { TransitionError } from "../src/types";

describe("Error Handling", () => {
let fs: FluentState;
Expand Down Expand Up @@ -41,6 +42,6 @@ describe("Error Handling", () => {

it("should throw an error when transitioning without a target state", () => {
fs.from("vegetable").to("diced");
expect(() => fs.transition()).to.throw("Transition error: No target state specified");
expect(() => fs.transition()).to.throw(TransitionError, "No target state specified");
});
});
Loading
Loading