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 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bemedev/app

v1.9.4

Published

A reprogramming method to code better

Readme

@bemedev/app

A TypeScript library for building finite state machines with a fully type-safe, declarative API. It models states, transitions, context, asynchronous operations, and reactive streams through a unified actors model.

The core idea: write machines as pure data, wire implementations separately, generate types automatically.

Demos

Live interactive demonstrations are available on Vercel:

Philosophy

The machine defines what can happen. The interpreter makes it happen.

A machine is purely declarative. Its configuration is plain data: state names, transitions, guard names, action names. It never calls external code, never imports side-effects. You can serialise, clone, inspect, and test it in complete isolation.

Machine config           provideOptions / addOptions
─────────────            ────────────────────────────
states: {                actions: {
  idle: {                  fetchUser: assign(...),
    on: {                  canFetch: ({ context }) => ...,
      FETCH: {           }
        guards: 'canFetch',
        target: 'loading',
        actions: 'fetchUser',
      }
    }
  }
}

The interpreter receives the machine and brings it to life at runtime. It holds context, processes events, schedules timers, and subscribes to actors.

Names, not references

Every action, guard, delay, and actor is referred to by a string name in the config. This is intentional:

  • The machine config is serialisable (JSON-friendly)
  • Implementations are swappable without touching the config
  • Tests can verify the config shape independently of side-effects
  • The CLI code generator can statically extract all names and produce accurate TypeScript types

Actors — two kinds of external work

| Actor type | Shape | Direction | Control | | ---------- | ------------------------ | -------------------------------- | ------------ | | emitters | () => Pausable<T> | Source → Machine (read-only) | None | | children | () => Interpreter<...> | Bidirectional | sendTo API |

An emitter is a pausable stream the machine listens to. It never receives events from the machine. A child is a nested interpreter the parent can talk to.

Installation

npm install @bemedev/app
# or
pnpm add @bemedev/app

Requirements: Node.js ≥ 24 · TypeScript ≥ 6.0

Quick Start

import { createMachine, interpret, typings } from '@bemedev/app';

// 1. Declare the machine (pure data)
const machine = createMachine(
  {
    initial: 'idle',
    states: {
      idle: { on: { START: '/running' } },
      running: { on: { STOP: '/idle' } },
    },
  },
  typings({ eventsMap: { START: 'primitive', STOP: 'primitive' } }),
);

// 2. Create and start an interpreter
const service = interpret(machine);
service.start();

// 3. Send events and read state
console.log(service.value); // 'idle'
service.send('START');
console.log(service.value); // 'running'
service.send('STOP');
console.log(service.value); // 'idle'

// 4. Dispose cleanly
await service[Symbol.asyncDispose]();

Table of Contents


1. Machine Configuration

createMachine(config, types?) is the entry point. The first argument is the machine config; the second is optional type information.

import { createMachine, typings } from '@bemedev/app';

const machine = createMachine(
  {
    // The state the machine starts in
    initial: 'idle',

    // Root-level actors (emitters/children) shared across all states
    actors: {/* optional */},

    // All states
    states: {
      idle: {
        // Entry/exit side-effects (by name)
        entry: 'onEnterIdle',
        exit: 'onExitIdle',

        // Event-driven transitions
        on: { FETCH: '/loading' },

        // Recurring timed action while this state is active
        activities: { POLL: 'refreshToken' },
      },

      loading: {
        // Automatic transition after a delay
        after: { TIMEOUT: '/error' },

        // Unconditional transitions evaluated on entry
        always: [{ guards: 'isDone', target: '/success' }, '/error'],

        on: {
          SUCCESS: { actions: 'storeData', target: '/success' },
          ERROR: '/error',
        },
      },

      success: {},
      error: {},
    },
  },
  typings({
    context: {
      data: typings.array('string'),
      error: typings.maybe('string'),
    },
    eventsMap: {
      FETCH: 'primitive',
      SUCCESS: { data: typings.array('string') },
      ERROR: { message: 'string' },
    },
  }),
);

createConfig(config)

Share a typed config object without creating a full machine:

import { createConfig } from '@bemedev/app';

export const myConfig = createConfig({
  initial: 'idle',
  states: { idle: {}, done: {} },
});

Synchronous vs Asynchronous Machines

By default, machines are asynchronous (all transitions and actions are wrapped in promises to naturally support async operations). If you require strict synchronous execution (e.g., for performance or integration with synchronous UI frameworks), you can opt into a synchronous machine via the sync typings configuration:

const syncMachine = createMachine(
  {
    initial: 'idle',
    states: { idle: {} },
  },
  // Setting sync to 'sync' forces synchronous execution
  { sync: 'sync' }),
);

Synchronous machines throw a type error if you attempt to use async configurations. The interpret function automatically detects and runs synchronous machines without creating promises.

Strict Configuration Type Checking (NoExtraKeysConfig)

To ensure your machine configs are free of spelling errors and invalid configuration keys, @bemedev/app enforces compile-time strictness. If you add any unrecognized options at the root or within state configuration blocks, TypeScript will raise an error.

For example, typing init instead of initial will produce a compilation error:

const machine = createMachine({
  init: 'idle', // ❌ Type error: Object literal may only specify known properties...
  states: { idle: {} },
});

This ensures that state hierarchies, actions, and guards are always defined using correct keywords.


2. Typings System

TypeScript cannot always infer complex generic types from nested object literals alone. @bemedev/app relies on @bemedev/typings to let you describe TypeScript types as plain schema values that the machine can read at compile and build time.

Schema Builders

Type schemas are built using type from @bemedev/typings, along with structural helpers (e.g. from @bemedev/typings/helpers or dynamically within the type callback):

import { type } from '@bemedev/typings';
import {
  array,
  partial,
  optional,
  record,
  union,
  litterals,
} from '@bemedev/typings/helpers';

// Define schemas
const user = type(({ partial }) =>
  partial({ name: 'string', age: 'number' }),
);

Supported Schemas

  • Primitives: 'string', 'number', 'boolean', 'undefined', 'never'.
  • Objects: partial({ ... }) for optional fields, any({ ... }) for strict objects.
  • Collections: array(...) for arrays, record(...) for maps, tuple(...) for tuples.
  • Unions & Literals: union(...) and litterals(...).

Full Machine Typings Example

To attach types, supply them as the third argument to createMachine:

import { createMachine } from '@bemedev/app';
import { type } from '@bemedev/typings';

const machine = createMachine(
  'auth-machine',
  {
    initial: 'idle',
    states: { idle: { on: { FETCH: '/loading' } }, loading: {} },
  },
  {
    // Public context (exposed via service.context)
    context: type(({ partial, array }) =>
      partial({
        items: array('string'),
        error: optional('string'),
        count: 'number',
      }),
    ),

    // Private context (not exposed to subscribers)
    pContext: type(({ partial }) => partial({ token: 'string' })),

    // All events the machine can receive
    eventsMap: type(({ partial, array }) => ({
      FETCH: 'never',
      SUCCESS: { data: array('string') },
      ERROR: { message: 'string' },
    })),

    // Force synchronous execution if true
    sync: true,
  },
);

3. Interpreter

The interpreter is the runtime engine. It receives a configured machine, holds state and context, processes events, schedules timers, and manages actor subscriptions.

import { interpret } from '@bemedev/app';

const service = interpret(machine, {
  // Initial public context (must satisfy the declared type)
  context: { items: [], error: undefined, count: 0 },

  // Initial private context (optional)
  pContext: { token: undefined },

  // 'strict' (default) — throws on unknown events/options
  // 'normal' — silently ignores unknown events
  mode: 'strict',

  // Use exact interval timing (default: true)
  exact: true,
});

// Must call start() before sending events
service.start();

// Send events — string shorthand or full event object
service.send('FETCH');
service.send({ type: 'SUCCESS', payload: { data: ['a', 'b'] } });

// Read state
service.value; // current state value
service.context; // current public context
service.status; // 'idle' | 'working' | 'stopped'
service.state; // full snapshot
service.config; // current state node config
service.tags; // active tags for the current state
service.mode; // 'strict' | 'normal'

// Pause / resume all timers and activities
service.pause();
service.resume();

// Stop the service (async — waits for all promises)
await service[Symbol.asyncDispose]();
// or synchronously:
service.dispose();

Adding options at runtime

addOptions mutates the service (useful for dynamic wiring):

service.addOptions(({ assign }) => ({
  actions: {
    storeData: assign('context.items', ({ event }) => event.payload.data),
  },
}));

provideOptions returns a new service (immutable):

const enrichedService = service.provideOptions(({ voidAction }) => ({
  actions: { log: voidAction(() => console.log('state changed')) },
}));

4. State Subscriptions

Subscribe to all state changes

The subscriber receives (previousState, currentState) on every transition:

const sub = service.subscribe((prev, curr) => {
  console.log(`${prev.value} → ${curr.value}`);
  console.log('New context:', curr.context);
});

// Later:
sub.unsubscribe();

Subscribe to specific events

React only to named events with an object subscriber:

const sub = service.subscribe({
  SUCCESS: ({ payload }) => {
    console.log('Got data:', payload.data);
  },
  ERROR: ({ payload }) => {
    console.error('Failed:', payload.message);
  },
  // Catch-all for any other event
  else: () => console.log('Other transition'),
});

sub.close();

5. Actions

Actions are side-effects that run during transitions. They are always declared by name in the config and implemented using options helpers in createOptions, provideOptions, or addOptions. The library provides a rich set of action helpers that cover state mutation, side-effects, event sending, and activity/timer management.

All helpers are injected as parameters of the options helper callback:

// Create strongly-typed options for Sync or Async machines
const options = machine.createOptions(({
  assign, swap, voidAction, batch, filter, erase, debounce,
  sendTo, resend, forceSend,
  pauseActivity, resumeActivity, stopActivity,
  pauseTimer, resumeTimer, stopTimer,
  isValue, isNotValue, isDefined, isNotDefined,
}) => ({
  actions:  { ... },
  guards:   { ... },
  delays:   { ... },
  actors:   { ... },
}));

5.1 assign

Updates context values using decomposed dot-notation paths. Supports single-key assignments or multi-variable array assignments.

actions: {
  // Set a leaf value
  increment: assign(
    'context.count',
    ({ context }) => context.count + 1,
  ),

  // Replace the entire context
  reset: assign('context', () => ({
    items: [],
    error: undefined,
    count: 0,
  })),

  // Assign multiple variables from an array-returning function
  updateCoordinates: assign(
    ['context.x', 'context.y'],
    () => [100, 200],
  ),

  // Scoped to an actor event — runs only when that actor emits
  storeData: assign('context.items', {
    'dataStream::next':  ({ payload }) => [payload],
    'dataStream::error': () => [],
  }),
}

The path follows @bemedev/decompose conventions: 'context', 'context.field', 'context.nested.deep'.

5.2 swap

Remaps and adapts standard functions into machine options using parameter decomposition powered by @bemedev/function-swap.

swap converts any standard function fn into a machine selector function (FnR), remapping parameter positions from state, context, or event payload paths. Because it returns a standard machine selector, swap can be used across actions, assign, guards, delays, and standalone options.

// 1. Inside assign (remap event payload into context assignment)
actions: {
  updateInput: assign(
    'context.input',
    swap(
      (value: string) => value.trim(),
      'WRITE',
    )({ '[0]': '[0].payload.value' }),
  ),
},

// 2. Inside guards (returns boolean predicate)
guards: {
  isAdult: swap(
    (age: number) => age >= 18,
  )({ '[0]': '[0].context.user.age' }),
},

// 3. Inside delays (returns number in ms)
delays: {
  RETRY_TIMEOUT: swap(
    (retryCount: number, baseMs: number) => retryCount * baseMs,
  )({
    '[0]': '[0].context.retries',
    '[1]': '[0].context.baseTimeout',
  }),
}

5.3 voidAction

Side-effect only — returns nothing, never modifies context.

actions: {
  logTransition: voidAction(({ event }) => {
    console.log('Event received:', event.type);
  }),

  // Scoped to an actor event
  handleStreamError: voidAction({
    'dataStream::error': ({ payload }) => {
      Sentry.captureException(payload);
    },
  }),
}

5.4 batch

Groups multiple actions into a single named action. Useful when a transition needs to perform several operations atomically.

actions: {
  clearForm: batch(
    erase('context.name'),
    erase('context.email'),
    erase('context.age'),
  ),

  // Compose existing actions via _legacy
  doubleIncrement: batch(
    _legacy.actions.increment!,
    _legacy.actions.increment!,
  ),
}

5.5 filter & erase

filter — removes elements from arrays, object arrays, or records stored in context:

actions: {
  // Keep only even numbers
  keepEvens:    filter('context.numbers', (n: number) => n % 2 === 0),

  // Keep active users
  keepActive:   filter('context.users', ({ active }) => active === true),

  // Keep high-scoring entries in a record
  keepTopScores: filter('context.scores', score => score >= 90),
}

erase — sets a context property to undefined (removing it from context):

actions: {
  clearError: erase('context.error'),
  clearToken: erase('context.pContext.token'),

  clearAll: batch(
    erase('context.items'),
    erase('context.error'),
  ),
}

5.6 debounce

Schedules a debounced context update after a specified time delay ms under a unique identifier id.

actions: {
  searchDebounced: debounce(
    assign('context.results', async ({ context }) => fetchResults(context.query)),
    { id: 'search-query', ms: 300 }
  ),
}

5.7 sendTo

Sends an event to a child service or actor from within an action.

actions: {
  // Send an event to child actor 'childWorker'
  notifyChild: sendTo(({ context }) => ({
    to: 'childWorker',
    event: { type: 'PING', payload: context.id },
  })),
}

5.8 resend & forceSend

Re-dispatch events from within an action back to the current machine.

  • resend(event) — only dispatches if the machine is not in a blocked state (e.g. 'stopped').
  • forceSend(event) — always dispatches, regardless of machine state.
actions: {
  retryFetch:      resend('FETCH'),
  alwaysIncrement: forceSend('INCREMENT'),
}

5.9 Activity & Timer Lifecycle Actions

Action helpers to manage active activity actors and timers directly:

  • pauseActivity(name), resumeActivity(name), stopActivity(name)
  • pauseTimer(name), resumeTimer(name), stopTimer(name)
actions: {
  pauseUpload:   pauseActivity('uploadWorker'),
  resumeUpload:  resumeActivity('uploadWorker'),
  stopUpload:    stopActivity('uploadWorker'),

  pauseTimeout:  pauseTimer('sessionTimer'),
  resumeTimeout: resumeTimer('sessionTimer'),
  cancelTimeout: stopTimer('sessionTimer'),
}

5.10 Async actions & AsyncOptions (AsyncMachine vs SyncMachine)

In AsyncMachine (created via createAsyncMachine or createMachine), action helpers (assign, voidAction, sendTo) accept async functions and support an optional AsyncOptions configuration object as their final parameter:

  • max: Optional timeout in milliseconds. If execution exceeds max ms, it is aborted via a timeout exception (withTimeout).
  • catch: A curried function (err) => Action that captures exceptions and executes a fallback action inline. If omitted, rejections flow to the interpreter's internal error channel.
  • then: An optional post-action (state) => Action executed sequentially after successful resolution.

Note: In SyncMachine (createSyncMachine), action execution is purely synchronous; AsyncOptions (max, catch, then) are not used in synchronous mode.

actions: {
  // Async assign with timeout limit, inline catch, and then chaining
  loadUser: assign(
    'context.user',
    async ({ event }) => {
      const res = await fetch(`/api/users/${event.payload.id}`);
      return res.json();
    },
    {
      // Enforce a 5-second timeout limit
      max: 5000,

      // Handle rejection by assigning the error message to context
      catch: (err) => assign('context.error', () => err.message),

      // Chain another action after successful user loading
      then: voidAction(({ context }) => {
        console.log(`User ${context.user.name} loaded successfully!`);
      }),
    },
  ),

  // Async sendTo with timeout and error handler
  dispatchTask: sendTo(
    async ({ context }) => ({
      to: 'workerService',
      event: { type: 'PROCESS', payload: await prepareData(context) },
    }),
    {
      max: 3000,
      catch: (err) => voidAction(() => console.error('Dispatch failed:', err)),
    }
  ),
}

6. Guards

Guards are predicates that gate transitions. They receive the current state snapshot and return a boolean (or Promise<boolean> in async machines).

6.1 Built-in Guard Helpers

All guard helpers evaluate context properties using decomposed path strings:

  • isValue(key, expectedValue) — returns true if property at key strictly equals expectedValue.
  • isNotValue(key, expectedValue) — returns true if property at key does not equal expectedValue.
  • isDefined(key) — returns true if property at key is defined (neither undefined nor null).
  • isNotDefined(key) — returns true if property at key is undefined or null.
machine.createOptions(
  ({ isValue, isNotValue, isDefined, isNotDefined }) => ({
    guards: {
      // Check property values
      isEmpty: isValue('context.items', []),
      hasToken: isNotValue('context.pContext.token', undefined),

      // Check presence or absence
      hasUserData: isDefined('context.user'),
      isUnauthenticated: isNotDefined('context.session'),
    },
  }),
);

6.2 Custom & Async Predicates

machine.provideOptions(({ isValue, swap }) => ({
  guards: {
    // Synchronous custom predicate
    isAuthenticated: ({ context }) =>
      context.token !== undefined && !isExpired(context.token),

    // Remapped predicate with swap
    isAdult: swap((age: number) => age >= 18)({
      '[0]': '[0].context.user.age',
    }),

    // Predicate with event payload
    isValidInput: ({ event }) =>
      event.type === 'SUBMIT' && event.payload.value.length > 0,

    // Async predicate — available in AsyncMachine / AsyncInterpreter
    // A rejected promise resolves to false (error-safe)
    hasPermission: async ({ context }) => {
      const res = await fetch(`/api/permissions/${context.userId}`);
      return res.ok;
    },
  },
}));

Async guards are fully supported in AsyncInterpreter. Predicates are evaluated sequentially and short-circuit on the first false (fail-fast). A predicate that throws or rejects is treated as false. Sync machines do not support async guards.

Using guards in config

Guards are referenced by name in on, after, and always:

states: {
  idle: {
    on: {
      // Single guard
      FETCH: { guards: 'isAuthenticated', target: '/loading' },

      // Multiple candidates — first match wins (OR semantics)
      SUBMIT: [
        { guards: 'isValid',   target: '/success' },
        { guards: 'hasErrors', target: '/error' },
        '/fallback',  // no guard → always matches (catch-all)
      ],
    },
    always: [
      { guards: 'isEmpty', target: '/empty' },
    ],
  },
}

AND/OR guard composition

Guards can be composed with and / or objects directly in the config:

on: {
  SUBMIT: {
    guards: { and: ['isAuthenticated', 'isValid'] },
    target: '/success',
  },
  RECOVER: {
    guards: { or: ['isAdmin', 'hasRetries'] },
    target: '/retry',
  },
}

7. Transitions: on, after, always

on — event-driven

on: {
  // Simple target
  CANCEL: '/idle',

  // With guard + actions
  SUBMIT: {
    guards:  'canSubmit',
    actions: 'validateForm',
    target:  '/loading',
  },

  // Multiple candidates — evaluated top-to-bottom, first match wins
  RESPOND: [
    { guards: 'isOk',    target: '/success' },
    { guards: 'isRetry', target: '/loading' },
    '/error',   // fallback — no guard
  ],
}

after — timed / delayed

The transition fires automatically after the named delay elapses. If multiple delays are defined, the shortest one whose guard passes wins.

// Simple: fixed number (ms)
const machine = createMachine(
  {
    initial: 'idle',
    states: {
      idle: { after: { POLL: '/refreshing' } },
      refreshing: { on: { DONE: '/idle' } },
    },
  },
  defaultT,
).provideOptions(() => ({ delays: { POLL: 5000 } }));
// → transitions to 'refreshing' after 5 s
// Dynamic: function of current state (or remapped with swap)
delays: {
  RETRY_DELAY: ({ context }) => context.retryCount * 1000,
  SWAPPED_DELAY: swap((count: number, ms: number) => count * ms)({
    '[0]': '[0].context.retryCount',
    '[1]': '[0].context.baseMs',
  }),
}
// Multiple delays — shortest passing guard wins
states: {
  waiting: {
    after: {
      FAST: { guards: 'networkAvailable', target: '/online' },
      SLOW: '/offline',
    },
  },
}
delays: { FAST: 2000, SLOW: 10000 }
// If 'networkAvailable' is true → FAST fires at 2 s
// Otherwise → SLOW fires at 10 s

always — immediate / eventless

Evaluated every time the state is entered, before any event is processed. First matching guard wins. No match → nothing happens.

states: {
  gate: {
    always: [
      { guards: 'isAdmin',    target: '/adminDashboard' },
      { guards: 'isLoggedIn', target: '/dashboard' },
      '/login',   // catch-all fallback
    ],
  },
}

Order matters. always is evaluated synchronously on entry. Circular chains (A always → B always → A) are treated as no-ops.


8. Activities

An activity is an action that fires repeatedly on a named interval while the state is active. It supports pause, resume, and stop controls.

const machine = createMachine(
  {
    initial: 'polling',
    states: {
      polling: {
        // 'refresh' action fires every POLL ms
        activities: { POLL: 'refresh' },
        on: {
          PAUSE: { actions: 'pausePoll' },
          RESUME: { actions: 'resumePoll' },
          STOP: { actions: 'stopPoll' },
        },
      },
    },
  },
  typings({
    context: { data: typings.maybe('string') },
    eventsMap: {
      PAUSE: 'primitive',
      RESUME: 'primitive',
      STOP: 'primitive',
    },
  }),
).provideOptions(
  ({ assign, pauseActivity, resumeActivity, stopActivity }) => ({
    actions: {
      refresh: assign(
        'context.data',
        async () => (await fetchData()).value,
      ),
      // The path '/polling::POLL' identifies the activity (state::delay)
      pausePoll: pauseActivity('/polling::POLL'),
      resumePoll: resumeActivity('/polling::POLL'),
      stopPoll: stopActivity('/polling::POLL'),
    },
    delays: { POLL: 3000 },
  }),
);

The activity refresh runs every 3 000 ms. PAUSE freezes it (timer stopped, context preserved), RESUME restarts the timer, STOP terminates it permanently for the current state visit.


9. Actors: Emitters

Core principle — emitters are NEVER touched during the flow.

An emitter is a pausable stream source that the machine subscribes to on state entry and unsubscribes from on state exit. The machine only reacts to its emissions. It never sends events to the emitter.

9.1 Lifecycle

┌─────────────────────┐   next/error/complete   ┌───────────────┐
│  Pausable<T>        │ ───────────────────────► │  Machine      │
│  (your RxJS obs,   │                           │  handlers     │
│   websocket, etc.) │ ◄── nothing              │               │
└─────────────────────┘                           └───────────────┘
        ▲                                                │
        │  subscribe + start  on state entry             │ stop on exit / re-entry
        └────────────────────────────────────────────────┘
  1. Config — declare the emitter name and its handlers:

    actors: {
      dataStream: {
        next:     { actions: ['appendData'] },
        error:    { actions: ['handleStreamError'] },
        complete: { actions: ['onStreamDone'] },
      },
    }
  2. Implementation — provide a factory () => Pausable<T>:

    .provideOptions(() => ({
      actors: {
        emitters: {
          dataStream: () => createPausable(myObservable$),
        },
      },
    }))

    Pausable<T> is a framework-agnostic interface exported by this library. Any object satisfying { subscribe, start, stop, pause, resume } qualifies. Use createPausable from @bemedev/rx-pausable to wrap RxJS observables — it is not a required dependency.

  3. Runtime — the interpreter manages everything automatically:

    • State entry → factory called → subscribe() + start()
    • Each emission → routed to the matching handler
    • State exit or service stop → stop()
    • Re-entering the state → new Pausable from scratch

9.2 Simple emitter — accumulating values

import { createMachine, typings, interpret } from '@bemedev/app';
import { createPausable } from '@bemedev/rx-pausable';
import { interval, map, take } from 'rxjs';

const machine = createMachine(
  {
    initial: 'active',
    actors: {
      ticker: {
        next: { actions: ['accumulate'] },
        complete: { actions: ['onDone'] },
      },
    },
    states: { active: {} },
  },
  typings({
    context: 'number',
    actorsMap: {
      emitters: { ticker: { next: 'number', error: 'never' } },
    },
  }),
).provideOptions(({ assign, voidAction }) => ({
  actions: {
    accumulate: assign('context', {
      'ticker::next': ({ payload, context }) => context + payload,
    }),
    onDone: voidAction(() => console.log('Stream complete')),
  },
  actors: {
    emitters: {
      ticker: () =>
        createPausable(
          interval(200).pipe(
            take(5),
            map(i => (i + 1) * 5),
          ),
        ),
    },
  },
}));

const service = interpret(machine, { context: 0 });
service.start();
// Emissions: 5, 10, 15, 20, 25
// context after all 5 emissions: 75

9.3 Error handling

When the source emits an error, the error handler fires. The machine remains healthy — it simply routes the error value to the declared actions.

actors: {
  live: {
    next:  { actions: ['store'] },
    error: { actions: ['logError'] },
  },
}
// ...
actions: {
  logError: voidAction({
    'live::error': ({ payload }) => console.error('Stream error:', payload),
  }),
}

9.4 State-scoped emitters

Emitters declared inside a specific state only run while that state is active. Exiting unsubscribes; re-entering creates a fresh subscription.

states: {
  idle:      { on: { START: '/streaming' } },
  streaming: {
    actors: {
      feed: { next: { actions: ['buffer'] } },
    },
    on: { STOP: '/idle' },
  },
}
// feed is only active while in 'streaming'.
// Entering 'idle' → unsubscribed.
// Re-entering 'streaming' → new Pausable created from scratch.

9.5 Emitters vs Children

| Aspect | Emitters | Children | | --------------- | --------------------------- | ----------------------------------- | | Direction | Source → Machine only | Bidirectional (parent ↔ child) | | Machine control | None — strictly passive | sendTo sends events to the child | | Lifecycle | subscribe / stop | interpret(...) / interpreter stop | | Pause / Resume | Via Pausable protocol | Via child interpreter | | Re-entry | New Pausable from scratch | Existing or new interpreter |


10. Actors: Children

A child actor is a nested interpreter. The parent can send events to it via sendTo, and the child's events bubble up via declared on handlers. Context can be mapped from child to parent (pContext).

10.1 Sending events to a child

const child = createMachine(
  {
    initial: 'idle',
    states: { idle: { on: { PING: '/pong' } }, pong: {} },
  },
  typings({ eventsMap: { PING: 'primitive' } }),
);

const parent = createMachine(
  {
    actors: {
      worker: {
        // When the child emits PONG, run 'notify' in the parent
        on: { PONG: { actions: ['notify'] } },
      },
    },
    initial: 'idle',
    states: {
      idle: {
        on: {
          // Forward PING to the child when the parent receives it
          PING: { actions: ['forwardPing'] },
        },
      },
    },
  },
  typings({
    eventsMap: { PING: 'primitive' },
    actorsMap: { children: { worker: { PING: 'primitive' } } },
  }),
).provideOptions(({ sendTo, voidAction }) => ({
  actions: {
    notify: voidAction(() => console.log('child reached pong')),
    forwardPing: sendTo(child)(() => ({ to: 'worker', event: 'PING' })),
  },
  actors: { children: { worker: () => interpret(child) } },
}));

10.2 Context mapping (child → parent pContext)

actors: {
  authService: {
    // Map child's entire context ('.') to parent.pContext.auth
    contexts: { '.': 'auth' },
  },
}
// Whenever authService.context changes, parent.pContext.auth is updated.
// Mapping is one-way — parent reads, never writes.

11. Tags

Tags are metadata labels on states. They let consumers ask "what category is the machine in?" without hard-coding state names. A state can carry multiple tags.

const machine = createMachine(
  {
    initial: 'idle',
    states: {
      idle: { tags: ['idle', 'ready'] },
      loading: { tags: ['busy'] },
      error: { tags: ['failed'] },
      success: { tags: ['done'] },
    },
  },
  typings({ eventsMap: {} }),
);

const service = interpret(machine);
service.start();

service.tags; // ['idle', 'ready']
service.send('FETCH');
service.tags; // ['busy']

Tags in action callbacks

Tag literals are propagated into provideOptions callbacks as a typed union — enabling narrowing inside actions:

.provideOptions(({ voidAction }) => ({
  actions: {
    renderUI: voidAction(({ tags }) => {
      // tags: 'idle' | 'ready' | 'busy' | 'failed' | 'done' | undefined
      if (tags === 'busy')   showSpinner();
      if (tags === 'failed') showErrorBanner();
    }),
  },
}))

12. Registry & Code Generation

@bemedev/app ships a CLI and a type-level registry that together give you full compile-time types for every machine in your project — paths, events, options, context — with zero manual annotation.

The CLI helpers are provided by the companion package @bemedev/app-cli, a complementary library for better typing and CLI-driven code generation, similar in purpose to TanStack Start.

The pattern is inspired by TanStack Router's declare module augmentation.

CLI Binary Entry Point

The package includes a built-in CLI executable. After installation, invoke commands directly via npx:

npx @bemedev/app generate
npx @bemedev/app watch

Or install globally to use the CLI without npx:

npm install -g @bemedev/app
app generate
app watch

12.1 Machine file convention

Name your machine files with the .machine.ts (or .fsm.ts) suffix:

src/
  auth/
    auth.machine.ts       ← discovered by the CLI
  checkout/
    checkout.machine.ts   ← discovered by the CLI
  app.gen.ts              ← generated — do not edit manually

Inside a machine file, call registerMachine to enroll the machine in the global registry:

// src/auth/auth.machine.ts
import { createMachine, registerMachine, typings } from '@bemedev/app';

const machine = createMachine({/* config */}, typings({/* types */}));

registerMachine('./src/auth/auth.machine.ts', machine);

export { machine };

12.2 CLI: generate

Run once to scan all machine files and produce app.gen.ts:

# Via npx (after npm install @bemedev/app)
npx @bemedev/app generate

# Custom output path
npx @bemedev/app generate --output src/app.gen.ts

# Exclude additional directories
npx @bemedev/app generate --excludes temp build coverage

# Preview output without writing to disk
npx @bemedev/app generate --dry-run | less

Or use the npm script defined in package.json:

pnpm run generate

12.3 CLI: watch / dev

Long-running watcher that regenerates app.gen.ts automatically on every machine file change. Ideal during development:

npx @bemedev/app watch
# or the alias:
npx @bemedev/app dev

# Alongside a dev server
pnpm run dev &
pnpm run generate:watch

Behaviour:

  1. Performs a full initial generation on startup.
  2. Watches all *.machine.ts and *.fsm.ts files (excludes node_modules, lib, dist by default).
  3. Debounces 300 ms of filesystem stability before regenerating (handles editor auto-saves and git checkouts gracefully).
  4. Logs every detected change to stderr.
  5. Responds to Ctrl+C with a clean watcher shutdown.

12.4 app.gen.ts and the Register interface

app.gen.ts is a TypeScript module augmentation file. It declares:

declare module '@bemedev/app' {
  interface Register {
    './src/auth/auth.machine.ts': {
      paths: { map: { '/idle': ..., '/loading': ... }; all: string };
      events: 'LOGIN' | 'LOGOUT' | 'REFRESH';
      options: {
        actions: 'setUser' | 'clearUser';
        guards:  'isLoggedIn';
        delays:  never;
        // ...
      };
      pContext?: { token: string | undefined };
      tags?: 'authenticated' | 'guest';
    };
    // ... one entry per discovered machine
  }
}

Import app.gen.ts once at your app entry point and every getMachine, registerMachine, and state path will be fully typed throughout the codebase:

// main.ts
import './app.gen';

12.5 registerMachine & getMachine

import { registerMachine, getMachine, MACHINES } from '@bemedev/app';

// Register a machine under a path key
registerMachine('./src/auth/auth.machine.ts', machine);

// Retrieve a machine by path key (fully typed via Register)
const authMachine = getMachine('./src/auth/auth.machine.ts');
// authMachine is typed to the exact shape declared in Register

// Access all registered machines (Record<string, AnyMachine>)
console.log(Object.keys(MACHINES));

13. Legacy Options (_legacy)

Both provideOptions and addOptions receive a second parameter { _legacy } containing all options defined in previous calls. This enables safe composition without manual cross-referencing.

Composing on a Machine

const machine = createMachine(config, types)
  .provideOptions(({ assign }) => ({
    actions: {
      increment: assign(
        'context.count',
        ({ context }) => context.count + 1,
      ),
    },
  }))
  .provideOptions(({ batch }, { _legacy }) => ({
    actions: {
      // Reuse 'increment' defined above — no import, no circular reference
      doubleIncrement: batch(
        _legacy.actions.increment!,
        _legacy.actions.increment!,
      ),
    },
  }));

Composing on an Interpreter

const service = interpret(machine, { context: 0 });

service.addOptions(({ assign }) => ({
  actions: { add5: assign('context', ({ context }) => context + 5) },
}));

service.addOptions(({ batch }, { _legacy }) => ({
  actions: { add10: batch(_legacy.actions.add5!, _legacy.actions.add5!) },
}));

_legacy properties

| Property | Content | | ------------------ | ----------------------------------- | | _legacy.actions | All previously defined actions | | _legacy.guards | All previously defined guards | | _legacy.delays | All previously defined delays | | _legacy.machines | All previously defined child actors | | _legacy.emitters | All previously defined emitters |

Key guarantees:

  • Frozen_legacy is immutable; mutations throw at runtime.
  • Cumulative — each call sees options from all previous calls, not just the immediately preceding one.
  • Type-safe — fully typed; IntelliSense completes option names.

14. Internal Utilities

These lower-level building blocks are used by the library internally. They are exported for advanced use cases such as building tooling, custom generators, or extending the framework.

14.1 BetterSet<T>

An enhanced Set with optional custom equality, extra collection operations (union, intersection, difference, symmetricDifference), and a richer API:

import { createBetterSet } from '@bemedev/app';

const s = createBetterSet<string>();
s.add('a', 'b', 'c');
s.has('b'); // true
s.isEmpty; // false
s.toArray; // ['a', 'b', 'c']
s.size; // 3

// Set algebra
const other = createBetterSet<string>();
other.add('b', 'd');

const common = (a: string, b: string) => a === b;
s.intersection(other, common).toArray; // ['b']
s.difference(other, common).toArray; // ['a', 'c']

// Custom equality (deduplicate by id)
const byId = createBetterSet<{ id: number }>((a, b) => a.id === b.id);
byId.add({ id: 1 }, { id: 1 }); // deduplicated — size = 1

14.2 parseTree

Traverses a machine's NodeConfig and returns a complete structural analysis in a single pass:

import { parseTree } from '@bemedev/app';

const result = parseTree(machine.config);

result.paths.all; // string[] — all state paths ('/idle', '/loading', ...)
result.paths.map; // typed map: path → NodeConfig
result.actions; // BetterSet<string> — all action names
result.guards; // BetterSet<string> — all guard names
result.delays; // BetterSet<string> — all delay names
result.emitters; // BetterSet<string> — all emitter names
result.children; // BetterSet<string> — all child actor names
result.events; // BetterSet<string> — all event names
result.tags; // BetterSet<string> — all tag values
result.pContextKeys; // BetterSet<string> — pContext mapping keys
result.flat; // flat record: path → NodeConfig

parseTree powers the CLI generator — it extracts everything the type generator needs from the machine config without executing any runtime code.

14.3 reduceGuards

Flattens nested AND/OR guard unions into a deduplicated flat array:

import { reduceGuards } from '@bemedev/app';

reduceGuards(
  { and: ['isLoggedIn', 'isAdmin'] },
  { or: ['hasRole', 'isOwner'] },
  'isActive',
);
// → ['isLoggedIn', 'isAdmin', 'hasRole', 'isOwner', 'isActive']

14.4 betterTimeout

A callback-based timeout helper designed to prevent long-running or hung async operations. It executes a callback after a specified delay, but throws a MAX_EXCEEDED error to the onError handler if the delay exceeds a specified maxTime threshold limit.

import { betterTimeout } from '@bemedev/app/utils';

betterTimeout({
  callback: () => {
    console.log('Task finished successfully');
  },
  onError: err => {
    console.error('Task timed out:', err.message); // "MAX_EXCEEDED"
  },
  ms: 3000,
  maxTime: 2000, // exceeds maxTime -> triggers onError with MAX_EXCEEDED error
});

15. API Reference

Machine creation

createMachine(config, types?)

| Parameter | Description | | --------- | --------------------------------------------------------------------------------------- | | config | Machine configuration — initial, states, actors? | | types | Type definitions via typings(...)context, eventsMap, actorsMap?, pContext? |

Returns a Machine instance. Chainable via .provideOptions(...).

createConfig(config)

Returns a typed config object (no Machine instance created).

Machine methods

| Method | Mutates | Returns | Description | | --------------------- | ------- | -------------- | ---------------------------------- | | .provideOptions(cb) | No | New Machine | Wire implementations (immutable) | | .addOptions(cb) | Yes | Options object | Add / overwrite options at runtime | | .clone() | No | New Machine | Deep clone the machine |

Utility helper exports

The root package now re-exports a small set of generic utility helpers from ./bemedev:

| Helper | Purpose | | -------------- | --------------------------------------------------- | | _any | Predicate helper that always returns true | | _unknown | Predicate helper for unknown checks | | expandFn | Expands value/function inputs into executable forms | | identify | Identity helper for strong type inference | | partialCall | Partial application helper | | partialCallO | Object-oriented variant of partial application | | switchV | Value-based switching helper | | switchValue | Typed switch helper for branching by value | | toArray | Normalizes value(s) into array form | | trueO | Object predicate helper returning true | | tupleOf | Tuple constructor helper preserving literal typings |

interpret(machine, options?)

| Option | Default | Description | | ---------- | ----------- | ------------------------- | | context | — | Initial public context | | pContext | undefined | Initial private context | | mode | 'strict' | 'strict' | 'normal' | | exact | true | Use exact interval timing |

Interpreter properties

| Property | Type | Description | | --------- | ---------------------------------- | --------------------------------- | | value | StateValue | Current state (string or nested) | | context | Tc | Current public context | | status | 'idle' \| 'working' \| 'stopped' | Lifecycle status | | state | StateExtended | Full state snapshot | | config | NodeConfig | Current state node configuration | | mode | 'strict' \| 'normal' | Current execution mode | | tags | string[] | Active tags for the current state |

Interpreter methods

| Method | Description | | -------------------------------------- | ------------------------------------------------ | | start() | Start the service and process entry actions | | send(event) | Send an event (string or { type, payload }) | | subscribe(subscriber) | Subscribe to state changes | | pause() | Pause activities and timers | | resume() | Resume after pause | | stop() | Stop the service | | addOptions(cb) | Mutate the service with new options | | provideOptions(cb) | Return a new service with additional options | | dispose() | Synchronous disposal | | await service[Symbol.asyncDispose]() | Async disposal (waits for in-flight promises) |

State configuration shape

{
  type?:       'atomic' | 'compound' | 'parallel' | 'final';
  initial?:    string;
  tags?:       string[];
  entry?:      ActionConfig;
  exit?:       ActionConfig;
  on?:         Record<string, TransitionConfig>;
  after?:      Record<string, TransitionConfig>;
  always?:     TransitionConfig | TransitionConfig[];
  activities?: Record<string, ActionConfig>;
  actors?:     Record<string, ActorConfig>;
  states?:     Record<string, StateDefinition>;
}

Transition configuration

type TransitionConfig =
  | string // Target path
  | { target?: string; guards?: GuardConfig; actions?: ActionConfig }
  | TransitionConfig[]; // Array — first match wins

Pausable<T> interface

type Pausable<T> = {
  subscribe: (observer: EmitterObserver<T>) => void;
  start: () => void; // begin consuming source
  stop: () => void; // stop and clean up
  pause: () => void; // buffer incoming values
  resume: () => void; // replay buffer then resume
};

type EmitterObserver<T> = {
  next: (value: T) => void;
  error: (err: any) => void;
  complete: () => void;
};

Typings utilities

@bemedev/app re-exports key type utilities from @bemedev/typings:

| Utility | Source | Purpose | | ------------------ | ------------------ | ------------------------------------------------------- | | inferT<Schema> | @bemedev/typings | Extracts TypeScript type from a schema definition | | inferO<Schema> | @bemedev/typings | Extracts TypeScript type from object schema definitions | | inferSh<Schema> | @bemedev/typings | Extracts schema shape | | Fn<Args, Return> | @bemedev/typings | Helper for strongly-typed functions |

For schema building, import builders directly from @bemedev/typings/helpers (e.g. array, record, partial, litterals, union, optional).

Advanced Exported Types

These advanced helper and registry types are exported from @bemedev/app for strict compile-time checks, tooling integration, or typing extension points:

| Type | Purpose | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | Action2<E, Pc, Tc, T> | Standard function signature for an action callback (takes StateExtended and returns a sync or async ActionResult). | | ActorsConfigMap | Core type representing the entire actors registry map (consisting of children, emitters, and promisees definitions). | | ToEventObject<T> | Utility type that converts event configurations into a unified EventObject interface. | | ToEvents<E, A> | Combines and maps standard events, child actors, emitters, and promisees into a unified, flat event map. | | DelayFunction2<E, Pc, Tc, T> | The signature for delayed transition functions (resolves to a number or a context-aware function returning a number). | | EmitterFunction2<E, Pc, Tc, T, R> | Type for emitter sources (takes StateExtended and returns a framework-agnostic Pausable<R> instance). | | PredicateS<E, Tc, T> | Type signature for guard predicate functions (takes a reduced state State<E, Tc, T> and returns boolean). |

CLI

npx @bemedev/app generate [--output path] [--excludes dirs...] [--dry-run]
npx @bemedev/app watch    [--output path] [--excludes dirs...]
npx @bemedev/app dev      [--output path] [--excludes dirs...]   # alias for watch

# Or if installed globally:
app generate [--output path] [--excludes dirs...] [--dry-run]
app watch    [--output path] [--excludes dirs...]
app dev      [--output path] [--excludes dirs...]   # alias for watch

Changelog

View CHANGELOG.md

Contributing

Contributions are welcome! Please open an issue or pull request on GitHub.

License

MIT

Author

chlbri[email protected]

GitHub profile · Website