npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@olympos/soter

v0.1.1

Published

Finite State Machine mixins and helpers for programming state logic.

Downloads

83

Readme

Soter - Finite State Machine

Soter is a lightweight, object-oriented finite state machine implementation in Javascript. Soter has zero-dependencies and is useful for both frontend and backend applications.

Soter's API will be changing over time. It is likely that there will be breaking changes among minor releases (0.1.0 -> 0.2.0 etc) because we are gathering input from others who find the package helpful. An example of one of the largest forseeable breaks is the soter() API and allowing it to be called without an object where merge() or another more detailed API will substitute the existing soter() API. Though it is probably that we will be able to leverage Typescript in supporting the use of soter() for both use cases.

Installation

npm install @olympos/soter

Quickstart

Here is a simple example of how to leverage Soter:

const matterMachine = soter(
  {
    state: "solid",
  },
  {
    melt: { origins: "solid", destination: "liquid" },
    evaporate: { origins: "liquid", destination: "gas" },
  }
);
console.log(matterMachine.state); // solid
matterMachine.trigger("melt"); // Trigger the melt transition
console.log(matterMachine.state); // liquid

Here is a class based approach with better type safety:

type MatterState = "solid" | "liquid" | "gas" | "plasma";
type MatterTrigger =
  | "melt"
  | "evaporate"
  | "sublimate"
  | "ionize"
  | "freeze"
  | "depose"
  | "condense"
  | "recombine";

class Matter {
  state: MatterState;

  constructor(state: MatterState) {
    this.state = state;
  }
}

const matterMachineDict: InstructionDict<MatterState, MatterTrigger, Matter> = {
  melt: { origins: "solid", destination: "liquid" },
  evaporate: { origins: "liquid", destination: "gas" },
  sublimate: { origins: "solid", destination: "gas" },
  ionize: { origins: "gas", destination: "plasma" },
};

// Initialize a Matter object and attach a state machine to it
const matter = soter(new Matter("solid"), matterMachineDict);
console.log(matter.state); // solid
matter.trigger("melt"); // Trigger the melt transition
console.log(matter.state); // liquid

Concepts

A state machine is a model of behavior composed of a finite number of states and transitions between those states. Within each state and transition some action can be performed. A state machine needs to start at some initial state. Below, we will look at some core concepts and how to work with them.

  • State: A condition or stage in a state machine. A State can describe a phase in a process or a mode of behavior.

  • Transition: A process or event that causes the state machine to change from one state to another.

  • Model: An entity that gets updated during transitions. It may also define actions that will be executed during transitions. This is also described as context.

  • Machine: An entity that manages and controls the model, states, transitions, and actions.

  • Trigger: An event that initiates a transition, the method that sends the signal to start a transition.

  • Action: An operation or task that is performed when a certain state is entered, exited, or during a transition.

Basics

In order to create an object with a state machine, it must be Stateful, or having a .state property that the machine can reference. Some examples of this are:

// Simple object untyped approach
const matter = {
  state: "solid",
};
// Class based typed approach
type MatterState = "solid" | "liquid" | "gas" | "plasma";
class Matter {
  state: MatterState;

  constructor(state: MatterState) {
    this.state = state;
  }
}
const matter = new Matter("solid");

You can create a very simple working state machine bound to matter like this:

import { soter } from "@olympos/soter";

const matterMachine = soter(matter, ["solid", "liquid"]);

You can now transition your state machine to any destination listed in the list above:

matterMachine.to("liquid");
console.log(matterMachine.state); // liquid

Calling machine on matter creates matterMachine which includes all of the base object's properties and methods while also attaching various state machine methods.

Transitions

In order to get the benefit of Soter with your objects local state. It is imperative to use Soter's builtin functionality to call all conditions, effects, and callbacks within your instance. Changing the state of the object directly will not have any effect on Soter because it explicity avoids adding any functionality on top of the already existing object.

.to(state: State, options?: TransitionOptions)

The .to() method is helpful for simple state transitions as demonstrated in the last example. Simply supply a state and if it exists transition to it without any checks or side effects.

const matter = soter(
  {
    state: "solid",
  }, // The object
  ["solid", "liquid"] // The available states
);

console.log(matter.state); // solid
matter.to("liquid");
console.log(matter.state); // liquid

.trigger(trigger: Trigger, props: any, options?: TransitionOptions)

In most use cases where finite state machines are needed, it is often helpful to have additional logic that happens before, during, and after transitions. This is where the .trigger() method is helpful.

import { InstructionDict } from "@olympos/soter";

type HeroState = "idle" | "sleeping";
type HeroTrigger = "patrol" | "sleep";

class Hero {
  state: HeroState;
  energy: number;

  constructor(state: HeroState) {
    this.state = state;
    this.energy = 1;
  }

  work() {
    console.log("The hero is expending energy!");
    this.energy--;
  }

  hasEnergy() {
    return this.energy > 0;
  }
}

const InstructionDict: InstructionDict<HeroState, HeroTrigger, Hero> = {
  patrol: {
    origins: "idle",
    destination: "idle",
    conditions: "hasEnergy",
    effects: "work",
  },
  sleep: {
    origins: "idle",
    destination: "sleeping",
  },
};

const hero = soter(new Hero("idle"), InstructionDict);
hero.trigger("patrol");
// The hero is expending energy!
hero.trigger("patrol"); // No log because condition is not met so the hero does not work

Passing Data

Data can be passed from the initial trigger function into all methods being called during the transition:

export class Matter {
  state: MatterState;
  temperature: number;
  pressure: number;

  constructor(state: MatterState, temperature?: number, pressure?: number) {
    this.state = state;
    this.temperature = temperature ?? 0;
    this.pressure = pressure ?? 101.325;
  }

  setEnvironment(props?: { temperature: number; pressure: number }) {
    const { temperature = 0, pressure = 101.325 } = props ?? {};
    this.temperature = temperature;
    this.pressure = pressure;
  }
}

export const matterMachineDict: InstructionDict<
  MatterState,
  MatterTrigger,
  Matter
> = {
  melt: [
    { origins: "solid", destination: "liquid", effects: "setEnvironment" },
  ],
  evaporate: [{ origins: "liquid", destination: "gas" }],
  sublimate: [{ origins: "solid", destination: "gas" }],
  ionize: [{ origins: "gas", destination: "plasma" }],
};

If we pass any props into the second argument of the trigger function, they will be passed to all conditions and all effects:

matterMachine.trigger("melt", { temperature: 20 });

One caveat is that we don't get type inference on the props, but you may cast them.

Configuration

The initial state machine may be configured with any of the following options like so:

const matter = soter(new Matter(), matterTransitionInstructions, {
  verbosity: false,
  throwExceptions: false,
  strictOrigins: false,
});

Further configuration in individual transitions may be changed and take priority:

matter.triggerWithOptions("evaporate", {
  throwExceptions: true,
});

More configuration options will come as Soter grows

Advanced

Debugging State Transitions is notoriously difficult. It is for this reason that each transition returns a structured object of the history of the context throughout the transition.

export class ExampleObject {
  state: ExampleObjectState;
  speed: number;
  energy: number;

  constructor(energy?: number) {
    this.state = "stopped";
    this.energy = energy ?? 1;
    this.speed = 0;
  }

  speedUp() {
    this.speed = 1;
    this.energy--;
  }

  slowDown() {
    this.speed = 0;
  }

  hasEnergy(): boolean {
    return this.energy > 0;
  }
}

export const exampleMachineDict: InstructionDict<
  ExampleObjectState,
  ExampleObjectTrigger,
  ExampleObject
> = {
  walk: {
    origins: ["stopped"],
    destination: "walking",
    effects: ["speedUp"],
    conditions: ["hasEnergy"],
  },
  stop: {
    origins: ["walking"],
    destination: "stopped",
  },
};

const myObject = new ExampleObject(1);
const objectMachine = soter(myObject, exampleMachineDict);
objectMachine.trigger("walk");
objectMachine.trigger("stop");
const response = objectMachine.trigger("walk"); // Will fail

console.log(response);

// {
//   success: false,
//   failure: {
//     type: "ConditionValue",
//     undefined: false,
//     trigger: "walk",
//     method: "hasEnergy",
//     context: { state: "stopped", speed: 1, energy: 0 },
//   },
//   initial: "stopped",
//   current: "stopped",
//   attempts: [
//     {
//       name: "walk",
//       success: false,
//       failure: {
//         type: "ConditionValue",
//         undefined: false,
//         trigger: "walk",
//         method: "hasEnergy",
//         context: { state: "stopped", speed: 1, energy: 0 },
//       },
//       conditions: [
//         {
//           name: "hasEnergy",
//           success: false,
//           context: { state: "stopped", speed: 1, energy: 0 },
//         },
//       ],
//       effects: [],
//       transition: {
//         origins: ["stopped"],
//         destination: "walking",
//         effects: ["speedUp"],
//         conditions: ["hasEnergy"],
//       },
//       context: { state: "stopped", speed: 1, energy: 0 },
//     },
//   ],
//   precontext: { state: "stopped", speed: 1, energy: 0 },
//   context: { state: "stopped", speed: 1, energy: 0 },
// };

Destructured for simpler design:

const { success, failure } = objectMachine.trigger("walk");

TODO

  • Update Readme with the new API: soter vs addStateMachine, instructions, etc
  • Add better lifecycles for StateMachine, TransitionRecords, and Lifecycles
    • [ ] ~~state.onExit~~
    • [ ] ~~state.onEnter~~
  • Allow separate lifecycle events for FSM and transitions?
  • Allow soter() to remove Context from first param
    • Doable by making instructions not have the state key and requiring Context to have the state key
    • Might make refactor to use any property for state more difficult in the future (if it ever happens)
  • Configurable .state key? .status, .otherState, etc
    • Might be too difficult to do with TS
  • Hiearchical State Machine
  • Visualization Tool
  • Some Absctraction
    • Trigger Function Abstraction for Errors
    • Instruction Initialization