FSM
@voltage/fsm provides finite state machines that integrate directly with NestJS routing and session storage. Each state in the machine maps to a controller endpoint — GET routes render the view for that state, POST/PUT/PATCH routes handle transitions. After a transition, the interceptor redirects the client (via HTMX) to the GET route for the new state automatically.
State and context are persisted to the session between requests, so the machine picks up exactly where it left off.
Installation
Section titled “Installation”yarn add @voltage/fsm @voltage/session @voltage/htmx @voltage/url @voltage/event-manager @voltage/loggerBasic usage
Section titled “Basic usage”Import FsmModule once in your root module:
import { FsmModule } from '@voltage/fsm';
@Module({ imports: [FsmModule],})export class AppModule {}Defining a machine
Section titled “Defining a machine”StateMachine() is a factory that returns a NestJS provider class. Call it with your state and context types and a configuration object:
import { StateMachine } from '@voltage/fsm';
export type CheckoutState = 'cart' | 'shipping' | 'payment' | 'confirmation';
export interface CheckoutContext { shippingAddress?: Address; paymentMethod?: string;}
export const CheckoutMachine = StateMachine<CheckoutState, CheckoutContext>({ name: 'checkout', defaultState: 'cart', defaultContext: {},});Register it as a provider in the module that owns the checkout flow:
@Module({ providers: [CheckoutMachine], controllers: [CheckoutController],})export class CheckoutModule {}Routing
Section titled “Routing”@Route()
Section titled “@Route()”@Route(MachineClass, ...states) associates a controller method with a machine and one or more states. The interceptor uses this metadata to decide whether the current request is valid for the machine’s state, and to resolve the redirect URL after a transition.
GET methods render the view for a state. POST/PUT/PATCH methods handle transitions. Use '*' as a wildcard to match any state:
import { Controller, Get, Post, Body } from '@nestjs/common';import { Route, Machine } from '@voltage/fsm';import { CheckoutMachine, CheckoutContext, CheckoutState } from './checkout.machine';
@Controller('checkout')export class CheckoutController {
@Get() @Route(CheckoutMachine, 'cart') cart(@Machine() machine: CheckoutMachine) { return <CartView items={machine.context.items} />; }
@Get('shipping') @Route(CheckoutMachine, 'shipping') shipping(@Machine() machine: CheckoutMachine) { return <ShippingView />; }
@Get('payment') @Route(CheckoutMachine, 'payment') payment(@Machine() machine: CheckoutMachine) { return <PaymentView address={machine.context.shippingAddress} />; }
@Get('confirmation') @Route(CheckoutMachine, 'confirmation') confirmation(@Machine() machine: CheckoutMachine) { return <ConfirmationView />; }}@Machine()
Section titled “@Machine()”@Machine() is a parameter decorator that injects the current machine instance. Use it to read state and context, and to call transition():
@Get()@Route(CheckoutMachine, 'cart', '*')cart(@Machine() machine: CheckoutMachine) { const { items } = machine.context;
return <CartView items={items} />;}The '*' wildcard above means this route serves as a fallback — if the machine is in any state that has no other matching GET handler, it redirects here. Useful for the entry point of a flow.
Transitions
Section titled “Transitions”Call machine.transition() inside a POST/PUT/PATCH handler to move to a new state. Return the machine instance from the handler — the interceptor detects this and redirects to the new state’s GET route automatically:
@Post('shipping')@Route(CheckoutMachine, 'cart')async toShipping( @Machine() machine: CheckoutMachine, @Body() body: ShippingBody,) { await machine.transition('shipping', { shippingAddress: body.address, });
return machine;}
@Post('payment')@Route(CheckoutMachine, 'shipping')async toPayment(@Machine() machine: CheckoutMachine) { await machine.transition('payment');
return machine;}The second argument to transition() controls context handling:
// Keep existing context unchangedawait machine.transition('payment');
// Merge partial context into existing contextawait machine.transition('payment', { shippingAddress: address });
// Reset context to defaultContext, then transitionawait machine.transition('cart', true);To send the user back to the beginning, reset() returns to defaultState with defaultContext:
@Post('restart')@Route(CheckoutMachine, '*')async restart(@Machine() machine: CheckoutMachine) { await machine.reset();
return machine;}Guards
Section titled “Guards”StateTransitionEvent is emitted before every transition. Listen to it to validate the transition and block it when conditions are not met by setting event.shouldTransition = false:
import { Injectable } from '@nestjs/common';import { OnEvent } from '@voltage/event-manager';import { StateTransitionEvent } from '@voltage/fsm';
@Injectable()export class CheckoutGuard { @OnEvent(StateTransitionEvent) async onTransition(event: StateTransitionEvent) { if (event.state === 'payment' && !event.machine.context.shippingAddress) { event.shouldTransition = false; } }}The event carries:
interface StateTransitionEvent { /** The machine attempting to transition. */ machine: StateMachineFacade; /** The target state. */ state: string; /** The context that will be set after the transition. */ nextContext: AnyContext; /** Set to false to abort the transition. */ shouldTransition: boolean;}Configuration
Section titled “Configuration”StateMachine() accepts the following options:
interface StateMachineConfiguration<State, Context> { /** Unique name for this machine. Used as the session key. */ name: string; /** The state the machine starts in, and returns to on reset(). */ defaultState: State; /** The context the machine starts with, and returns to on reset(). */ defaultContext: Context;}