npm.io
1.7.0 • Published 3 weeks ago

@bemedev/app

Licence
MIT
Version
1.7.0
Deps
13
Size
1.3 MB
Vulns
0
Weekly
0

@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.


Philosophy

Expand

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

Expand

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

Expand

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

Expand

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

Expand
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

Expand

Actions are side-effects that run during transitions. They are always declared by name in the config and implemented in provideOptions / addOptions. The library provides a set of action helpers that cover the most common patterns.

All helpers are injected as parameters of the provideOptions callback:

machine.provideOptions(({
  assign, voidAction, batch, filter, erase,
  resend, forceSend, isValue, isNotValue,
  pauseActivity, resumeActivity, stopActivity,
  sendTo,
}) => ({
  actions:  { ... },
  guards:   { ... },
  delays:   { ... },
  actors:   { ... },
}));
5.1 assign

Updates context values using decomposed dot-notation paths.

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,
  })),

  // 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'.

You can also perform multi-variable assignments by passing an array of keys:

// Assign multiple variables from an array-returning function
updateCoordinates: assign(['context.x', 'context.y'], () => [100, 200]);
5.2 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.3 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.4 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:

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

  clearAll: batch(
    erase('context.items'),
    erase('context.error'),
  ),
}
5.5 resend & forceSend

Re-dispatch events from within an action.

  • 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.6 Async actions & AsyncOptions

All action helpers (assign, voidAction, sendTo) accept async functions. The interpreter's action pipeline is fully async and awaits each step sequentially.

You can configure execution behavior by passing an options object as the last argument:

  • catch: A curried function (err) => Action that handles rejections inline. If omitted, errors flow to the interpreter's internal _addError channel.
  • then: An optional action to execute sequentially after successful resolution.
  • max: An optional timeout in milliseconds. If execution exceeds this, it is aborted.
actions: {
  // Async assign with inline catch and then chaining
  loadUser: assign(
    'context.user',
    async ({ event }) => {
      const res = await fetch(`/api/users/${event.payload.id}`);
      return res.json();
    },
    {
      // 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!`);
      }),

      // Enforce a 5-second timeout limit
      max: 5000,
    },
  ),

  // Async void — no options → errors propagate to the interpreter
  trackAnalytics: voidAction(
    async ({ context }) => {
      await analytics.track('state_change', { userId: context.userId });
    },
  ),
}


6. Guards

Expand

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

machine.provideOptions(({ isValue, isNotValue }) => ({
  guards: {
    // Built-in helpers — compare a context path to a value
    isEmpty: isValue('context.items', []),
    hasToken: isNotValue('context.pContext.token', undefined),

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

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

    // Async predicate — only available in 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

Expand
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
delays: {
  RETRY_DELAY: ({ context }) => context.retryCount * 1000,
}
// 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

Expand

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

Expand

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

Expand

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

Expand

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

Expand

@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)

Expand

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

Expand

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

Expand
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

chlbribri_lvi@icloud.com

GitHub profile · Website

Keywords